diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index b77ca248cc1..81b8e625e2d 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -19,6 +19,7 @@ from typing_extensions import override from aws_lambda_powertools.event_handler import content_types +from aws_lambda_powertools.event_handler.depends import DependencyResolutionError from aws_lambda_powertools.event_handler.exception_handling import ExceptionHandlerManager from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ResponseSizeExceededError, ServiceError from aws_lambda_powertools.event_handler.openapi.config import OpenAPIConfig @@ -2948,6 +2949,16 @@ def exception_handler(self, exc_class: type[Exception] | list[type[Exception]]): return self.exception_handler_manager.exception_handler(exc_class=exc_class) def _call_exception_handler(self, exp: Exception, route: Route) -> ResponseBuilder | None: + # Depends() wraps what a dependency raised in DependencyResolutionError. Handle the original exception + # as if the route had raised it, unless a handler is registered for DependencyResolutionError itself. + # If nothing handles it, this returns None and the caller re-raises the DependencyResolutionError. + if ( + isinstance(exp, DependencyResolutionError) + and isinstance(exp.__cause__, Exception) + and DependencyResolutionError not in self.exception_handler_manager.get_registered_handlers() + ): + exp = exp.__cause__ + handler = self.exception_handler_manager.lookup_exception_handler(type(exp)) if handler: try: diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py index 7c819dc9d10..6f65349e34a 100644 --- a/tests/functional/event_handler/required_dependencies/test_depends.py +++ b/tests/functional/event_handler/required_dependencies/test_depends.py @@ -1,12 +1,21 @@ """Tests for the Depends() dependency injection feature using Annotated.""" +import asyncio import json from typing import Annotated import pytest -from aws_lambda_powertools.event_handler import APIGatewayHttpResolver +from aws_lambda_powertools.event_handler import ( + ALBResolver, + APIGatewayHttpResolver, + APIGatewayRestResolver, + Response, + VPCLatticeV2Resolver, + content_types, +) from aws_lambda_powertools.event_handler.depends import DependencyResolutionError, Depends +from aws_lambda_powertools.event_handler.exceptions import UnauthorizedError from aws_lambda_powertools.event_handler.request import Request from tests.functional.utils import load_event @@ -507,3 +516,132 @@ def handler(path: Annotated[str, Depends(get_path)]): assert result["statusCode"] == 200 body = json.loads(result["body"]) assert body["path"] == "/my/path" + + +# --------------------------------------------------------------------------- +# Exceptions raised by dependencies are handled like exceptions raised by the route +# --------------------------------------------------------------------------- + + +def get_current_user() -> str: + """Auth dependency rejecting a request without credentials, like the docs example.""" + raise UnauthorizedError("Missing authentication") + + +def test_depends_service_error_returns_its_status_code(): + """A ServiceError raised by a dependency returns its HTTP response, as if the route raised it.""" + app = APIGatewayHttpResolver() + + @app.post("/my/path") + def handler(user: Annotated[str, Depends(get_current_user)]): + return {"user": user} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 401 + assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"} + + +def test_depends_exception_handled_by_exception_handler(): + """An exception_handler receives the exception the dependency raised, not DependencyResolutionError.""" + app = APIGatewayHttpResolver() + + class TenantNotFoundError(Exception): + pass + + @app.exception_handler(TenantNotFoundError) + def handle_tenant_not_found(ex: TenantNotFoundError): + return Response(status_code=404, content_type=content_types.TEXT_PLAIN, body=str(ex)) + + def get_tenant() -> str: + raise TenantNotFoundError("Tenant acme not found") + + @app.post("/my/path") + def handler(tenant: Annotated[str, Depends(get_tenant)]): + return {"tenant": tenant} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 404 + assert result["body"] == "Tenant acme not found" + + +def test_depends_nested_service_error_returns_its_status_code(): + """A ServiceError raised by a nested dependency returns its HTTP response.""" + app = APIGatewayHttpResolver() + + def get_tenant(user: Annotated[str, Depends(get_current_user)]) -> str: + return f"tenant-of-{user}" + + @app.post("/my/path") + def handler(tenant: Annotated[str, Depends(get_tenant)]): + return {"tenant": tenant} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 401 + assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"} + + +def test_depends_dependency_resolution_error_handler_takes_precedence(): + """An exception_handler for DependencyResolutionError still receives it, with the original exception as cause.""" + app = APIGatewayHttpResolver() + + @app.exception_handler(DependencyResolutionError) + def handle_dependency_error(ex: DependencyResolutionError): + return Response(status_code=500, content_type=content_types.TEXT_PLAIN, body=type(ex.__cause__).__name__) + + @app.post("/my/path") + def handler(user: Annotated[str, Depends(get_current_user)]): + return {"user": user} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 500 + assert result["body"] == "UnauthorizedError" + + +def test_depends_service_error_returns_its_status_code_with_resolve_async(): + """resolve_async() also returns the HTTP response of a ServiceError raised by a dependency.""" + app = APIGatewayHttpResolver() + + @app.post("/my/path") + async def handler(user: Annotated[str, Depends(get_current_user)]): + return {"user": user} + + result = asyncio.run(app.resolve_async(API_GW_V2_EVENT, {})) + assert result["statusCode"] == 401 + assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"} + + +def test_depends_exception_with_resolve_async_raises_dependency_resolution_error(): + """With resolve_async(), an exception that no handler handles still raises DependencyResolutionError.""" + app = APIGatewayHttpResolver() + + def broken() -> str: + raise ValueError("boom") + + @app.post("/my/path") + async def handler(val: Annotated[str, Depends(broken)]): + return {"val": val} + + with pytest.raises(DependencyResolutionError, match="broken.*boom"): + asyncio.run(app.resolve_async(API_GW_V2_EVENT, {})) + + +@pytest.mark.parametrize( + "resolver, event_file, path", + [ + pytest.param(APIGatewayRestResolver, "apiGatewayProxyEvent.json", "/my/path", id="rest-api"), + pytest.param(APIGatewayHttpResolver, "apiGatewayProxyV2Event_GET.json", "/my/path", id="http-api"), + pytest.param(ALBResolver, "albEvent.json", "/lambda", id="alb"), + pytest.param(VPCLatticeV2Resolver, "vpcLatticeV2Event.json", "/newpath", id="vpc-lattice-v2"), + ], +) +def test_depends_service_error_returns_its_status_code_for_each_resolver(resolver, event_file, path): + """Each proxy resolver returns the HTTP response of a ServiceError raised by a dependency.""" + app = resolver() + + @app.get(path) + def handler(user: Annotated[str, Depends(get_current_user)]): + return {"user": user} + + result = app.resolve(load_event(event_file), {}) + assert result["statusCode"] == 401 + assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"}