From acd6d3b6721b93f3753985e4e0da8a50fc2f2eaa Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:26 -0700 Subject: [PATCH 1/7] fix(event_handlers): Bedrock Agent kwargs filtering and response media type With validation off, every event parameter was injected as a kwarg, so a query parameter missing from the handler signature raised TypeError. Only parameters matching the route path keys are injected now. A Response without a content type produced a 'null' media type key in responseBody. It defaults to application/json now. --- .../event_handler/bedrock_agent.py | 11 ++++-- .../_pydantic/test_bedrock_agent.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/event_handler/bedrock_agent.py b/aws_lambda_powertools/event_handler/bedrock_agent.py index 6fef1044b97..29cd41e07d4 100644 --- a/aws_lambda_powertools/event_handler/bedrock_agent.py +++ b/aws_lambda_powertools/event_handler/bedrock_agent.py @@ -49,7 +49,7 @@ def build(self, event: BedrockAgentEvent, *args) -> dict[str, Any]: "httpMethod": event.http_method, "httpStatusCode": self.response.status_code, "responseBody": { - self.response.content_type: { + self.response.content_type or "application/json": { "body": body, }, }, @@ -346,8 +346,13 @@ def _convert_matches_into_route_keys(self, match: Match) -> dict[str, str]: # In Bedrock Agents, all the parameters come inside the "parameters" key, not on the apiPath # So we have to search for route parameters in the parameters key parameters: dict[str, str] = {} - if match.groupdict() and self.current_event.parameters: - parameters = {parameter["name"]: parameter["value"] for parameter in self.current_event.parameters} + route_keys = match.groupdict() + if route_keys and self.current_event.parameters: + parameters = { + parameter["name"]: parameter["value"] + for parameter in self.current_event.parameters + if parameter["name"] in route_keys + } return parameters @override diff --git a/tests/functional/event_handler/_pydantic/test_bedrock_agent.py b/tests/functional/event_handler/_pydantic/test_bedrock_agent.py index 964668eb4cb..86da6c1b4be 100644 --- a/tests/functional/event_handler/_pydantic/test_bedrock_agent.py +++ b/tests/functional/event_handler/_pydantic/test_bedrock_agent.py @@ -418,3 +418,39 @@ def claims() -> dict[str, Any]: body = result["response"]["responseBody"]["application/json"]["body"] assert "잔액은 1,000원입니다 💰" in body assert json.loads(body) == {"output": "잔액은 1,000원입니다 💰"} + + +def test_bedrock_agent_ignores_non_path_params_when_validation_is_off(): + # GIVEN a Bedrock Agent event with a parameter that is not a path parameter + event = load_event("bedrockAgentEventWithPathParams.json") + event["parameters"] = [*event["parameters"], {"type": "string", "name": "verbose", "value": "true"}] + + app = BedrockAgentResolver(enable_validation=False) + + @app.get("/claims/", description="Gets claims by ID") + def claims(claim_id: str): + return {"claim_id": claim_id} + + # WHEN calling the event handler + result = app(event, {}) + + # THEN only path parameters are injected as kwargs and the handler resolves + assert result["response"]["httpStatusCode"] == 200 + body = json.loads(result["response"]["responseBody"]["application/json"]["body"]) + assert body == {"claim_id": "123"} + + +def test_bedrock_agent_response_without_content_type_defaults_to_json(): + # GIVEN a handler returning a Response without a content type + app = BedrockAgentResolver(enable_validation=False) + + @app.get("/claims", description="Gets claims") + def claims(): + return Response(status_code=200, body="ok") + + # WHEN calling the event handler + result = app(load_event("bedrockAgentEvent.json"), {}) + + # THEN the response body media type defaults to application/json instead of "null" + assert "null" not in result["response"]["responseBody"] + assert result["response"]["responseBody"]["application/json"]["body"] == "ok" From bea74f2b6030a0ddffca12397f54753eadde7588 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:26 -0700 Subject: [PATCH 2/7] fix(event_handlers): return decorated function when AppSync Events path is invalid The registry returned None after warning about an invalid path, so the decorated function name was bound to None. The function is returned unregistered instead. --- .../event_handler/events_appsync/_registry.py | 4 ++-- .../appsync/test_appsync_events_resolvers.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/event_handler/events_appsync/_registry.py b/aws_lambda_powertools/event_handler/events_appsync/_registry.py index 8c682327706..0d025181818 100644 --- a/aws_lambda_powertools/event_handler/events_appsync/_registry.py +++ b/aws_lambda_powertools/event_handler/events_appsync/_registry.py @@ -43,7 +43,7 @@ def register( A Callable """ - def _register(func) -> Callable | None: + def _register(func) -> Callable: if not is_valid_path(path): warnings.warn( f"The path `{path}` registered for `{self.kind_resolver}` is not valid and will be skipped." @@ -53,7 +53,7 @@ def _register(func) -> Callable | None: stacklevel=2, category=PowertoolsUserWarning, ) - return None + return func logger.debug( f"Adding resolver `{func.__name__}` for path `{path}` and kind_resolver `{self.kind_resolver}`", diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py index a38440377de..1755b266d01 100644 --- a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py +++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_events_resolvers.py @@ -1643,3 +1643,19 @@ def handle_events(): # WHEN we resolve the event with unauthorized route with pytest.raises(UnauthorizedException): app.resolve(mock_event, lambda_context) + + +def test_invalid_path_registration_still_returns_function(): + # GIVEN an AppSyncEventsResolver + app = AppSyncEventsResolver() + + # WHEN registering a resolver for an invalid path + with pytest.warns(PowertoolsUserWarning, match="is not valid and will be skipped"): + + @app.on_publish(path="/default/*/sub") + def handler(payload): + return payload + + # THEN the decorated function is returned instead of None + assert handler is not None + assert handler("payload") == "payload" From 531e53a7bb712cb21cadb1f7f479748149c3827f Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:26 -0700 Subject: [PATCH 3/7] fix(event_handlers): clear AppSync context after exception handler runs resolve() returned the exception handler result before clear_context(), so app.context leaked into the next invocation. Clearing now happens in a finally block, keeping the coroutine exemption. --- .../event_handler/appsync.py | 22 ++++++++++-------- .../appsync/test_appsync_single_resolvers.py | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/aws_lambda_powertools/event_handler/appsync.py b/aws_lambda_powertools/event_handler/appsync.py index 253fad15d74..940b1a1e16c 100644 --- a/aws_lambda_powertools/event_handler/appsync.py +++ b/aws_lambda_powertools/event_handler/appsync.py @@ -147,6 +147,7 @@ def lambda_handler(event, context): self.lambda_context = context Router.lambda_context = context + response = None try: if isinstance(event, list): Router.current_batch_event = [data_model(e) for e in event] @@ -157,16 +158,17 @@ def lambda_handler(event, context): except Exception as exp: response_builder = self.exception_handler_manager.lookup_exception_handler(type(exp)) if response_builder: - return response_builder(exp) - raise - - # We don't clear the context for coroutines because we don't have control over the event loop. - # If we clean the context immediately, it might not be available when the coroutine is actually executed. - # For single async operations, the context should be cleaned up manually after the coroutine completes. - # See: https://github.com/aws-powertools/powertools-lambda-python/issues/5290 - # REVIEW: Review this support in Powertools V4 - if not asyncio.iscoroutine(response): - self.clear_context() + response = response_builder(exp) + else: + raise + finally: + # We don't clear the context for coroutines because we don't have control over the event loop. + # If we clean the context immediately, it might not be available when the coroutine is actually executed. + # For single async operations, the context should be cleaned up manually after the coroutine completes. + # See: https://github.com/aws-powertools/powertools-lambda-python/issues/5290 + # REVIEW: Review this support in Powertools V4 + if not asyncio.iscoroutine(response): + self.clear_context() return response diff --git a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py index 4ef902c340a..9246bd4ef1c 100644 --- a/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py +++ b/tests/functional/event_handler/required_dependencies/appsync/test_appsync_single_resolvers.py @@ -387,3 +387,26 @@ def create_something(id: str): # noqa AA03 VNE003 # THEN the return must be the Exception Handler error message assert result["message"] == "error" + + +def test_exception_handler_clears_context(): + # GIVEN an AppSyncResolver with an exception handler and a resolver that appends context + mock_event = load_event("appSyncDirectResolver.json") + + app = AppSyncResolver() + + @app.exception_handler(ValueError) + def handle_value_error(ex: ValueError): + return {"error": str(ex)} + + @app.resolver(field_name="createSomething") + def create_something(id: str): # noqa AA03 VNE003 + app.append_context(invocation="data") + raise ValueError("something went wrong") + + # WHEN resolving an event that triggers the exception handler + result = app.resolve(mock_event, {}) + + # THEN the handler result is returned and the context does not leak into the next invocation + assert result == {"error": "something went wrong"} + assert app.context == {} From 59bc3a3615a622a93d2626130f392761e10ec644 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:32 -0700 Subject: [PATCH 4/7] fix(event_handlers): warn on duplicate routes registered in Router Router silently overwrote a duplicate route registration, last one wins, while the resolver warns. Router now emits the same warning. --- .../event_handler/api_gateway.py | 6 ++++++ .../required_dependencies/test_router.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 70b099269db..c5263b21f99 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -3284,6 +3284,12 @@ def register_route(func: AnyCallableT) -> AnyCallableT: status_code, ) + if route_key in self._routes: + warnings.warn( + f"A route like this was already registered. method: '{', '.join(methods)}' rule: '{rule}'", + stacklevel=2, + ) + # Collate Middleware for routes if middlewares is not None: for handler in middlewares: diff --git a/tests/functional/event_handler/required_dependencies/test_router.py b/tests/functional/event_handler/required_dependencies/test_router.py index 05c2260c8ee..2c6c91488aa 100644 --- a/tests/functional/event_handler/required_dependencies/test_router.py +++ b/tests/functional/event_handler/required_dependencies/test_router.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from aws_lambda_powertools.event_handler import ( ALBResolver, APIGatewayHttpResolver, @@ -76,3 +78,20 @@ def foo(): app.include_router(router) result = app(load_event("lambdaFunctionUrlEvent.json"), {}) assert result["body"] == "routed" + + +def test_router_warns_on_duplicate_route(): + # GIVEN a Router with a registered route + router = APIGatewayRouter() + + @router.get("/my/path") + def first(): + return {"fn": "first"} + + # WHEN registering the same route again + # THEN a warning is emitted like the resolver does + with pytest.warns(UserWarning, match="A route like this was already registered"): + + @router.get("/my/path") + def second(): + return {"fn": "second"} From c2acd533c5074f9ccdb3c257d195d2fdafd876a6 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:32 -0700 Subject: [PATCH 5/7] fix(event_handlers): use http path instead of method in Function URL base path _get_base_path compared request_context.http.method against the stage prefix instead of http.path. --- .../event_handler/lambda_function_url.py | 2 +- .../test_lambda_function_url.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/aws_lambda_powertools/event_handler/lambda_function_url.py b/aws_lambda_powertools/event_handler/lambda_function_url.py index cbd92a00b6e..37b8ca061c1 100644 --- a/aws_lambda_powertools/event_handler/lambda_function_url.py +++ b/aws_lambda_powertools/event_handler/lambda_function_url.py @@ -52,6 +52,6 @@ def lambda_handler(event, context): def _get_base_path(self) -> str: stage = self.current_event.request_context.stage - if stage and stage != "$default" and self.current_event.request_context.http.method.startswith(f"/{stage}"): + if stage and stage != "$default" and self.current_event.request_context.http.path.startswith(f"/{stage}"): return f"/{stage}" return "" diff --git a/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py b/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py index cdb0abb4d91..ef7eaf3166d 100644 --- a/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py +++ b/tests/functional/event_handler/required_dependencies/test_lambda_function_url.py @@ -82,3 +82,18 @@ def foo(): # THEN process event correctly # AND return 404 because the event doesn't match any known route assert result["statusCode"] == 404 + + +def test_lambda_function_url_get_base_path_checks_http_path(): + # GIVEN a Lambda Function Url event with a custom stage prefixing the path + event = load_event("lambdaFunctionUrlEvent.json") + event["requestContext"]["stage"] = "live" + event["rawPath"] = "/live/my/path" + event["requestContext"]["http"]["path"] = "/live/my/path" + + app = LambdaFunctionUrlResolver() + app.current_event = LambdaFunctionUrlEvent(event) + + # WHEN deriving the base path + # THEN it comes from the http path, not the http method + assert app._get_base_path() == "/live" From 3498dc0709f6ca9a8dac23201fc06d1ea46ddd15 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Sun, 18 Oct 2026 17:50:33 -0700 Subject: [PATCH 6/7] fix(event_handlers): resolve class dependencies via __init__ annotations Depends(SomeClass) read annotations with get_type_hints on the class, which returns class-level attributes, so Request injection and nested Depends declared on __init__ were never found. Hints are now read from __init__ when the dependency is a class. Closes #8584 --- .../event_handler/depends.py | 9 ++++- .../required_dependencies/test_depends.py | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/aws_lambda_powertools/event_handler/depends.py b/aws_lambda_powertools/event_handler/depends.py index f05167c63d9..26a0b06b32d 100644 --- a/aws_lambda_powertools/event_handler/depends.py +++ b/aws_lambda_powertools/event_handler/depends.py @@ -98,6 +98,11 @@ def _get_depends_from_annotation(annotation: Any) -> Depends | None: return None +def _annotation_target(func: Callable[..., Any]) -> Callable[..., Any]: + """A class carries its dependency annotations on __init__, not on the class itself.""" + return func.__init__ if isinstance(func, type) else func # type: ignore[misc] + + def _has_depends(func: Callable[..., Any]) -> bool: """Check if a callable has any Depends() parameters, without importing pydantic.""" try: @@ -118,7 +123,7 @@ def build_dependency_tree(func: Callable[..., Any]) -> DependencyTree: annotations and recursively builds the tree — all without importing pydantic. """ try: - hints = get_type_hints(func, include_extras=True) + hints = get_type_hints(_annotation_target(func), include_extras=True) except Exception: return DependencyTree() @@ -198,7 +203,7 @@ def solve_dependencies( # Inject Request if the dependency declares it if request is not None: try: - hints = get_type_hints(use_fn) + hints = get_type_hints(_annotation_target(use_fn)) except Exception: # pragma: no cover - defensive for broken annotations hints = {} for param_name, annotation in hints.items(): diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py index 6f65349e34a..bdebed9220f 100644 --- a/tests/functional/event_handler/required_dependencies/test_depends.py +++ b/tests/functional/event_handler/required_dependencies/test_depends.py @@ -645,3 +645,40 @@ def handler(user: Annotated[str, Depends(get_current_user)]): result = app.resolve(load_event(event_file), {}) assert result["statusCode"] == 401 assert json.loads(result["body"]) == {"statusCode": 401, "message": "Missing authentication"} + + +def test_depends_class_dependency_receives_request(): + """A class dependency gets Request injected into its __init__.""" + app = APIGatewayHttpResolver() + + class Service: + def __init__(self, request: Request): + self.path = request.resolved_event.path + + @app.post("/my/path") + def handler(svc: Annotated[Service, Depends(Service)]): + return {"path": svc.path} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"path": "/my/path"} + + +def test_depends_class_dependency_with_nested_depends(): + """A class dependency resolves its own Depends() parameters declared on __init__.""" + app = APIGatewayHttpResolver() + + def get_tenant() -> str: + return "tenant-1" + + class Service: + def __init__(self, tenant: Annotated[str, Depends(get_tenant)]): + self.tenant = tenant + + @app.post("/my/path") + def handler(svc: Annotated[Service, Depends(Service)]): + return {"tenant": svc.tenant} + + result = app(API_GW_V2_EVENT, {}) + assert result["statusCode"] == 200 + assert json.loads(result["body"]) == {"tenant": "tenant-1"} From 5f6435d73955d509186bc85c0020c809de5064d5 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Sun, 25 Oct 2026 18:43:51 +0000 Subject: [PATCH 7/7] style: format test_depends.py after merging develop --- .../event_handler/required_dependencies/test_depends.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/functional/event_handler/required_dependencies/test_depends.py b/tests/functional/event_handler/required_dependencies/test_depends.py index 2c47b4c4ee6..31f0370c4d6 100644 --- a/tests/functional/event_handler/required_dependencies/test_depends.py +++ b/tests/functional/event_handler/required_dependencies/test_depends.py @@ -682,6 +682,8 @@ def handler(svc: Annotated[Service, Depends(Service)]): result = app(API_GW_V2_EVENT, {}) assert result["statusCode"] == 200 assert json.loads(result["body"]) == {"tenant": "tenant-1"} + + def test_depends_with_none_default_is_resolved(): """A Depends() parameter that also declares ``= None`` is still resolved (Python 3.10 wraps it in Optional).""" app = APIGatewayHttpResolver()