Skip to content

Commit 781f60e

Browse files
fix(event_handler): keep catch-all exception handlers off 404s and adapt exception handler returns (#8624)
fix(event_handler): keep catch-all handlers off 404s and adapt exception handler returns - An unmatched route no longer resolves to a handler registered for Exception or BaseException. No exception was raised, so a catch-all 500 handler (as the docs suggest) turned every unmatched route into a 500. Handlers for NotFoundError and ServiceError still apply. - Whatever an exception handler returns now goes through _to_response, like a route's return value: Response(body=None) on ALB becomes "" instead of null (ALB 502), and dict / (dict, status) returns are serialized instead of raising AttributeError. Fixes #8579 Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 9c6ca41 commit 781f60e

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

‎aws_lambda_powertools/event_handler/api_gateway.py‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2765,7 +2765,7 @@ def not_found_handler():
27652765
_headers["Access-Control-Allow-Methods"] = CORSConfig.build_allow_methods(self._cors_methods)
27662766
return Response(status_code=204, content_type=None, headers=_headers, body="")
27672767

2768-
custom_not_found_handler = self.exception_handler_manager.lookup_exception_handler(NotFoundError)
2768+
custom_not_found_handler = self._lookup_not_found_handler()
27692769
if custom_not_found_handler:
27702770
return custom_not_found_handler(NotFoundError())
27712771

@@ -2933,7 +2933,7 @@ def not_found_handler():
29332933
return Response(status_code=204, content_type=None, headers=_headers, body="")
29342934

29352935
# Customer registered 404 route? Call it.
2936-
custom_not_found_handler = self.exception_handler_manager.lookup_exception_handler(NotFoundError)
2936+
custom_not_found_handler = self._lookup_not_found_handler()
29372937
if custom_not_found_handler:
29382938
return custom_not_found_handler(NotFoundError())
29392939

@@ -3008,6 +3008,21 @@ def not_found(self, func: Callable | None = None):
30083008
def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]):
30093009
return self.exception_handler_manager.exception_handler(exc_class=exc_class)
30103010

3011+
def _lookup_not_found_handler(self) -> Callable | None:
3012+
"""Find the handler for an unmatched route, ignoring catch-all handlers.
3013+
3014+
No exception was raised for an unmatched route, so a handler registered for `Exception` or `BaseException`
3015+
(e.g. a generic 500 handler) must not turn it into its own response. Handlers for `NotFoundError` and
3016+
`ServiceError` still apply.
3017+
"""
3018+
handlers = self.exception_handler_manager.get_registered_handlers()
3019+
for cls in NotFoundError.__mro__:
3020+
if cls in (Exception, BaseException):
3021+
break
3022+
if cls in handlers:
3023+
return handlers[cls]
3024+
return None
3025+
30113026
def _call_exception_handler(self, exp: Exception, route: Route) -> ResponseBuilder | None:
30123027
# Depends() wraps what a dependency raised in DependencyResolutionError. Handle the original exception
30133028
# as if the route had raised it, unless a handler is registered for DependencyResolutionError itself.
@@ -3022,7 +3037,12 @@ def _call_exception_handler(self, exp: Exception, route: Route) -> ResponseBuild
30223037
handler = self.exception_handler_manager.lookup_exception_handler(type(exp))
30233038
if handler:
30243039
try:
3025-
return self._response_builder_class(response=handler(exp), serializer=self._serializer, route=route)
3040+
# Adapt the handler's return value like a route's (dict/tuple, ALB null body)
3041+
return self._response_builder_class(
3042+
response=self._to_response(handler(exp)), # type: ignore[arg-type]
3043+
serializer=self._serializer,
3044+
route=route,
3045+
)
30263046
except ServiceError as service_error:
30273047
exp = service_error
30283048

@@ -3480,7 +3500,6 @@ def _build_response(self, response_builder: ResponseBuilder) -> dict[str, Any]:
34803500
if handled_response is None:
34813501
raise
34823502

3483-
handled_response.response = cast(Response, self._to_response(handled_response.response))
34843503
response = super()._build_response(handled_response)
34853504
# Validate once more without invoking an exception handler recursively.
34863505
self._validate_response_size(response)

‎tests/functional/event_handler/required_dependencies/test_api_gateway.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,91 @@ def handle_not_found(_) -> Response:
17851785
assert result["statusCode"] == 404
17861786

17871787

1788+
def test_catch_all_exception_handler_does_not_hijack_not_found():
1789+
# GIVEN a resolver with a catch-all exception handler, as suggested in the docs
1790+
app = APIGatewayRestResolver()
1791+
1792+
@app.exception_handler(Exception)
1793+
def handle_everything(ex: Exception) -> Response:
1794+
return Response(status_code=500, content_type=content_types.TEXT_PLAIN, body="generic handler")
1795+
1796+
@app.get("/other")
1797+
def get_other():
1798+
return {}
1799+
1800+
# WHEN calling the event handler for an unmatched route
1801+
result = app(LOAD_GW_EVENT, {})
1802+
1803+
# THEN the default 404 is returned instead of the catch-all response
1804+
assert result["statusCode"] == 404
1805+
assert json.loads(result["body"]) == {"statusCode": 404, "message": "Not found"}
1806+
1807+
1808+
def test_service_error_handler_still_handles_not_found():
1809+
# GIVEN a resolver with a handler for ServiceError, which NotFoundError inherits from
1810+
app = APIGatewayRestResolver()
1811+
1812+
@app.exception_handler(ServiceError)
1813+
def handle_service_error(ex: ServiceError) -> Response:
1814+
return Response(status_code=ex.status_code, content_type=content_types.TEXT_PLAIN, body="custom format")
1815+
1816+
# WHEN calling the event handler for an unmatched route
1817+
result = app(LOAD_GW_EVENT, {})
1818+
1819+
# THEN the ServiceError handler keeps formatting the 404
1820+
assert result["statusCode"] == 404
1821+
assert result["body"] == "custom format"
1822+
1823+
1824+
def test_exception_handler_none_body_on_alb_is_converted_to_empty_string():
1825+
# GIVEN an ALB resolver whose exception handler returns a Response with no body
1826+
app = ALBResolver()
1827+
1828+
@app.exception_handler(ValueError)
1829+
def handle_value_error(ex: ValueError) -> Response:
1830+
return Response(status_code=400, content_type=content_types.TEXT_PLAIN, body=None)
1831+
1832+
@app.get("/lambda")
1833+
def get_lambda():
1834+
raise ValueError("Foo!")
1835+
1836+
# WHEN calling the event handler
1837+
result = app(load_event("albEvent.json"), {})
1838+
1839+
# THEN the body is an empty string, as it is for a route returning the same Response, since ALB rejects null
1840+
assert result["statusCode"] == 400
1841+
assert result["body"] == ""
1842+
1843+
1844+
@pytest.mark.parametrize(
1845+
"handler_result, expected_status",
1846+
[
1847+
({"error": "Foo!"}, 200),
1848+
(({"error": "Foo!"}, 400), 400),
1849+
],
1850+
ids=["dict", "tuple"],
1851+
)
1852+
def test_exception_handler_dict_and_tuple_returns_are_serialized(handler_result, expected_status):
1853+
# GIVEN an exception handler that returns a dict or a (dict, status_code) tuple, like a route can
1854+
app = APIGatewayRestResolver()
1855+
1856+
@app.exception_handler(ValueError)
1857+
def handle_value_error(ex: ValueError):
1858+
return handler_result
1859+
1860+
@app.get("/my/path")
1861+
def get_lambda():
1862+
raise ValueError("Foo!")
1863+
1864+
# WHEN calling the event handler
1865+
result = app(LOAD_GW_EVENT, {})
1866+
1867+
# THEN the result is serialized the same way a route return value is
1868+
assert result["statusCode"] == expected_status
1869+
assert result["multiValueHeaders"]["Content-Type"] == [content_types.APPLICATION_JSON]
1870+
assert json.loads(result["body"]) == {"error": "Foo!"}
1871+
1872+
17881873
def test_exception_handler_raises_service_error(json_dump):
17891874
# GIVEN an exception handler raises a ServiceError (BadRequestError)
17901875
app = ApiGatewayResolver()

‎tests/functional/event_handler/required_dependencies/test_resolve_async.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ def get_lambda():
185185
assert response["statusCode"] == 404
186186
assert response["body"] == '{"error": "custom 404"}'
187187

188+
def test_catch_all_exception_handler_does_not_hijack_not_found(self):
189+
# GIVEN a catch-all exception handler
190+
app = APIGatewayRestResolver()
191+
192+
@app.exception_handler(Exception)
193+
def handle_everything(exc):
194+
return Response(500, content_types.TEXT_PLAIN, "generic handler")
195+
196+
# WHEN resolving with no matching route
197+
_setup_app(app, API_REST_EVENT)
198+
result = asyncio.run(app._resolve_async())
199+
200+
# THEN the default 404 is returned
201+
response = result.build(app.current_event, app._cors)
202+
assert response["statusCode"] == 404
203+
188204
def test_cors_preflight_returns_204(self):
189205
# GIVEN a resolver with CORS enabled
190206
app = APIGatewayRestResolver(cors=CORSConfig())

0 commit comments

Comments
 (0)