Skip to content

Commit a0373bb

Browse files
author
Avin E M
committed
fix(http-resolver): fix OPTIONS preflight returning 500 when CORS configured
HttpResolverLocal overrode _resolve_async, _call_route_async, _run_middleware_chain_async, and _handle_not_found_async, duplicating the parent's logic without the CORS preflight branch. OPTIONS requests fell through to the not-found handler and, if a generic exception handler was registered, returned 500 with no CORS headers. Replace all four overrides with a single thin _resolve_async that delegates to super()._resolve_async() and serializes the returned ResponseBuilder to dict. The parent already handles route matching, CORS preflight (OPTIONS → 204), not-found, and exception handling correctly. Fixes #8267
1 parent 683ba5f commit a0373bb

2 files changed

Lines changed: 222 additions & 110 deletions

File tree

aws_lambda_powertools/event_handler/http_resolver.py

Lines changed: 6 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import base64
4-
import inspect
54
import warnings
65
from typing import TYPE_CHECKING, Any, Callable
76
from urllib.parse import parse_qs
@@ -10,10 +9,7 @@
109
ApiGatewayResolver,
1110
BaseRouter,
1211
ProxyEventType,
13-
Response,
14-
Route,
1512
)
16-
from aws_lambda_powertools.event_handler.middlewares.async_utils import wrap_middleware_async
1713
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
1814
from aws_lambda_powertools.utilities.data_classes.common import BaseProxyEvent
1915

@@ -240,113 +236,13 @@ def _get_base_path(self) -> str:
240236
return ""
241237

242238
async def _resolve_async(self) -> dict: # type: ignore[override]
243-
"""Async version of resolve that supports async handlers."""
244-
method = self.current_event.http_method.upper()
245-
path = self._remove_prefix(self.current_event.path)
246-
247-
registered_routes = self._static_routes + self._dynamic_routes
248-
249-
for route in registered_routes:
250-
if method != route.method:
251-
continue
252-
match_results = route.rule.match(path)
253-
if match_results:
254-
self.append_context(_route=route, _path=path)
255-
route_keys = self._convert_matches_into_route_keys(match_results)
256-
return await self._call_route_async(route, route_keys)
257-
258-
# Handle not found
259-
return await self._handle_not_found_async()
260-
261-
async def _call_route_async(self, route: Route, route_arguments: dict[str, str]) -> dict: # type: ignore[override]
262-
"""Call route handler, supporting both sync and async handlers."""
263-
from aws_lambda_powertools.event_handler.api_gateway import ResponseBuilder
264-
265-
try:
266-
self._reset_processed_stack()
267-
268-
# Get the route args (may be modified by validation middleware)
269-
self.append_context(_route_args=route_arguments)
270-
271-
# Run middleware chain (sync for now, handlers can be async)
272-
response = await self._run_middleware_chain_async(route)
273-
274-
response_builder: ResponseBuilder = ResponseBuilder(
275-
response=response,
276-
serializer=self._serializer,
277-
route=route,
278-
)
279-
280-
return response_builder.build(self.current_event, self._cors)
281-
282-
except Exception as exc:
283-
exc_response_builder = self._call_exception_handler(exc, route)
284-
if exc_response_builder:
285-
return exc_response_builder.build(self.current_event, self._cors)
286-
raise
287-
288-
async def _run_middleware_chain_async(self, route: Route) -> Response:
289-
"""Run the middleware chain, awaiting async handlers."""
290-
# Build middleware list
291-
all_middlewares: list[Callable[..., Any]] = []
292-
293-
# Determine if validation should be enabled for this route
294-
# If route has explicit enable_validation setting, use it; otherwise, use resolver's global setting
295-
route_validation_enabled = (
296-
route.enable_validation if route.enable_validation is not None else self._enable_validation
297-
)
298-
299-
if route_validation_enabled and hasattr(self, "_request_validation_middleware"):
300-
all_middlewares.append(self._request_validation_middleware)
301-
302-
all_middlewares.extend(self._router_middlewares + route.middlewares)
303-
304-
if route_validation_enabled and hasattr(self, "_response_validation_middleware"):
305-
all_middlewares.append(self._response_validation_middleware)
306-
307-
# Create the final handler that calls the route function
308-
async def final_handler(app):
309-
route_args = app.context.get("_route_args", {})
310-
result = route.func(**route_args)
311-
312-
# Await if coroutine
313-
if inspect.iscoroutine(result):
314-
result = await result
315-
316-
return self._to_response(result)
317-
318-
# Build middleware chain from end to start
319-
next_handler = final_handler
320-
321-
for middleware in reversed(all_middlewares):
322-
next_handler = wrap_middleware_async(middleware, next_handler)
323-
324-
return await next_handler(self)
325-
326-
async def _handle_not_found_async(self, method: str = "", path: str = "") -> dict: # type: ignore[override]
327-
"""Handle 404 responses, using custom not_found handler if registered."""
328-
from http import HTTPStatus
329-
330-
from aws_lambda_powertools.event_handler.api_gateway import ResponseBuilder
331-
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
332-
333-
# Check for custom not_found handler
334-
custom_not_found_handler = self.exception_handler_manager.lookup_exception_handler(NotFoundError)
335-
if custom_not_found_handler:
336-
response = custom_not_found_handler(NotFoundError())
337-
else:
338-
response = Response(
339-
status_code=HTTPStatus.NOT_FOUND.value,
340-
content_type="application/json",
341-
body={"statusCode": HTTPStatus.NOT_FOUND.value, "message": "Not found"},
342-
)
343-
344-
response_builder: ResponseBuilder = ResponseBuilder(
345-
response=response,
346-
serializer=self._serializer,
347-
route=None,
348-
)
239+
"""Thin async resolver: delegates entirely to the parent and serializes to dict.
349240
241+
The parent's _resolve_async handles route matching, CORS preflight, not-found
242+
logic, and exception handling. The only adaptation needed here is converting
243+
the returned ResponseBuilder into the dict format that asgi_handler expects.
244+
"""
245+
response_builder = await super()._resolve_async()
350246
return response_builder.build(self.current_event, self._cors)
351247

352248
async def asgi_handler(self, scope: dict, receive: Callable, send: Callable) -> None:

tests/functional/event_handler/required_dependencies/test_http_resolver.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,3 +1242,219 @@ def hello():
12421242

12431243
# THEN it returns 404 (method mismatch is treated as not found)
12441244
assert captured["status_code"] == 404
1245+
1246+
1247+
# =============================================================================
1248+
# CORS Tests (issue #8267)
1249+
# =============================================================================
1250+
1251+
1252+
@pytest.mark.asyncio
1253+
async def test_cors_options_preflight_returns_204():
1254+
# GIVEN an app with CORSConfig and a POST route
1255+
from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
1256+
1257+
app = HttpResolverLocal(cors=CORSConfig(allow_origin="*"))
1258+
1259+
@app.post("/items")
1260+
def create_item():
1261+
return {"ok": True}
1262+
1263+
# WHEN a browser sends a CORS preflight OPTIONS request
1264+
scope = {
1265+
"type": "http",
1266+
"method": "OPTIONS",
1267+
"path": "/items",
1268+
"query_string": b"",
1269+
"headers": [
1270+
(b"origin", b"http://localhost:3000"),
1271+
(b"access-control-request-method", b"POST"),
1272+
],
1273+
}
1274+
1275+
receive = make_asgi_receive()
1276+
captured: dict[str, Any] = {"status_code": None, "headers": []}
1277+
1278+
async def send(message: dict[str, Any]) -> None:
1279+
await asyncio.sleep(0)
1280+
if message["type"] == "http.response.start":
1281+
captured["status_code"] = message["status"]
1282+
captured["headers"].extend(message.get("headers", []))
1283+
1284+
await app(scope, receive, send)
1285+
1286+
# THEN it returns 204 with CORS headers (not 500 or 404)
1287+
assert captured["status_code"] == 204
1288+
1289+
header_names = [name.lower() for name, _ in captured["headers"]]
1290+
assert b"access-control-allow-origin" in header_names
1291+
assert b"access-control-allow-methods" in header_names
1292+
1293+
1294+
@pytest.mark.asyncio
1295+
async def test_cors_options_preflight_with_exception_handler_does_not_return_500():
1296+
# GIVEN an app with CORSConfig and a generic exception handler that returns 500
1297+
import json
1298+
1299+
from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
1300+
1301+
app = HttpResolverLocal(cors=CORSConfig(allow_origin="*"))
1302+
1303+
@app.post("/items")
1304+
def create_item():
1305+
return {"ok": True}
1306+
1307+
@app.exception_handler(Exception)
1308+
def handle_server_error(ex: Exception):
1309+
return Response(
1310+
status_code=500,
1311+
content_type="application/json",
1312+
body=json.dumps({"error": "internal"}),
1313+
)
1314+
1315+
# WHEN a browser sends a CORS preflight OPTIONS request
1316+
scope = {
1317+
"type": "http",
1318+
"method": "OPTIONS",
1319+
"path": "/items",
1320+
"query_string": b"",
1321+
"headers": [
1322+
(b"origin", b"http://localhost:3000"),
1323+
(b"access-control-request-method", b"POST"),
1324+
],
1325+
}
1326+
1327+
receive = make_asgi_receive()
1328+
captured: dict[str, Any] = {"status_code": None, "headers": []}
1329+
1330+
async def send(message: dict[str, Any]) -> None:
1331+
await asyncio.sleep(0)
1332+
if message["type"] == "http.response.start":
1333+
captured["status_code"] = message["status"]
1334+
captured["headers"].extend(message.get("headers", []))
1335+
1336+
await app(scope, receive, send)
1337+
1338+
# THEN the OPTIONS request returns 204, not 500
1339+
assert captured["status_code"] == 204
1340+
header_names = [name.lower() for name, _ in captured["headers"]]
1341+
assert b"access-control-allow-origin" in header_names
1342+
1343+
1344+
@pytest.mark.asyncio
1345+
async def test_no_cors_options_returns_404():
1346+
# GIVEN an app WITHOUT CORSConfig
1347+
app = HttpResolverLocal()
1348+
1349+
@app.post("/items")
1350+
def create_item():
1351+
return {"ok": True}
1352+
1353+
# WHEN a browser sends an OPTIONS request (no CORS configured)
1354+
scope = {
1355+
"type": "http",
1356+
"method": "OPTIONS",
1357+
"path": "/items",
1358+
"query_string": b"",
1359+
"headers": [],
1360+
}
1361+
1362+
receive = make_asgi_receive()
1363+
send, captured = make_asgi_send()
1364+
1365+
await app(scope, receive, send)
1366+
1367+
# THEN it returns 404 (no CORS config, no special handling)
1368+
assert captured["status_code"] == 404
1369+
1370+
1371+
@pytest.mark.asyncio
1372+
async def test_cors_options_includes_allowed_methods_header():
1373+
# GIVEN an app with CORSConfig and multiple routes
1374+
from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
1375+
1376+
app = HttpResolverLocal(cors=CORSConfig(allow_origin="https://example.com"))
1377+
1378+
@app.get("/resource")
1379+
def get_resource():
1380+
return {"method": "GET"}
1381+
1382+
@app.post("/resource")
1383+
def post_resource():
1384+
return {"method": "POST"}
1385+
1386+
# WHEN an OPTIONS preflight is sent
1387+
scope = {
1388+
"type": "http",
1389+
"method": "OPTIONS",
1390+
"path": "/resource",
1391+
"query_string": b"",
1392+
"headers": [
1393+
(b"origin", b"https://example.com"),
1394+
(b"access-control-request-method", b"GET"),
1395+
],
1396+
}
1397+
1398+
receive = make_asgi_receive()
1399+
captured: dict[str, Any] = {"status_code": None, "headers": []}
1400+
1401+
async def send(message: dict[str, Any]) -> None:
1402+
await asyncio.sleep(0)
1403+
if message["type"] == "http.response.start":
1404+
captured["status_code"] = message["status"]
1405+
captured["headers"].extend(message.get("headers", []))
1406+
1407+
await app(scope, receive, send)
1408+
1409+
# THEN 204 is returned with Access-Control-Allow-Methods header
1410+
assert captured["status_code"] == 204
1411+
allow_methods_headers = [v for name, v in captured["headers"] if name.lower() == b"access-control-allow-methods"]
1412+
assert len(allow_methods_headers) == 1
1413+
1414+
1415+
@pytest.mark.asyncio
1416+
async def test_cors_disallowed_header_not_in_allow_headers():
1417+
# GIVEN an app with CORSConfig that only allows specific headers
1418+
from aws_lambda_powertools.event_handler.api_gateway import CORSConfig
1419+
1420+
app = HttpResolverLocal(cors=CORSConfig(allow_origin="*", allow_headers=["X-Custom-Allowed"]))
1421+
1422+
@app.post("/items")
1423+
def create_item():
1424+
return {"ok": True}
1425+
1426+
# WHEN a preflight requests an unlisted header
1427+
scope = {
1428+
"type": "http",
1429+
"method": "OPTIONS",
1430+
"path": "/items",
1431+
"query_string": b"",
1432+
"headers": [
1433+
(b"origin", b"http://localhost:3000"),
1434+
(b"access-control-request-method", b"POST"),
1435+
(b"access-control-request-headers", b"X-Not-Allowed"),
1436+
],
1437+
}
1438+
1439+
receive = make_asgi_receive()
1440+
captured: dict[str, Any] = {"status_code": None, "headers": []}
1441+
1442+
async def send(message: dict[str, Any]) -> None:
1443+
await asyncio.sleep(0)
1444+
if message["type"] == "http.response.start":
1445+
captured["status_code"] = message["status"]
1446+
captured["headers"].extend(message.get("headers", []))
1447+
1448+
await app(scope, receive, send)
1449+
1450+
# THEN the server still returns 204 (browser enforces the rejection, not the server)
1451+
assert captured["status_code"] == 204
1452+
1453+
# AND the unlisted header is absent from Access-Control-Allow-Headers
1454+
allow_headers_value = next(
1455+
(v.decode() for name, v in captured["headers"] if name.lower() == b"access-control-allow-headers"),
1456+
"",
1457+
)
1458+
assert "X-Not-Allowed" not in allow_headers_value
1459+
# AND the explicitly allowed header IS present
1460+
assert "X-Custom-Allowed" in allow_headers_value

0 commit comments

Comments
 (0)