Skip to content

Commit fb0e8a2

Browse files
committed
fix(event_handler): clear context when route resolution raises
resolve() and resolve_async() only cleared the routing context after a response was built, so an exception escaping a route handler left the previous invocation's context in place, including the cached Request. A warm Lambda container could then hand that stale Request to the next invocation. Clear the context in a finally block instead. The ALB response size validation no longer needs its own clean-up on error, and the JWT middleware test now expects the whole context to be gone after a failing handler rather than only the claims.
1 parent 5b74209 commit fb0e8a2

5 files changed

Lines changed: 68 additions & 20 deletions

File tree

‎aws_lambda_powertools/event_handler/api_gateway.py‎

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2560,7 +2560,10 @@ def resolve(self, event: Mapping[str, Any], context: LambdaContext) -> dict[str,
25602560
BaseRouter.current_event = self._to_proxy_event(cast(dict, event))
25612561
BaseRouter.lambda_context = context
25622562

2563-
response = self._build_response(self._resolve())
2563+
try:
2564+
response = self._build_response(self._resolve())
2565+
finally:
2566+
self.clear_context()
25642567

25652568
# Debug print Processed Middlewares
25662569
if self._debug:
@@ -2569,8 +2572,6 @@ def resolve(self, event: Mapping[str, Any], context: LambdaContext) -> dict[str,
25692572
print("\n".join(self.processed_stack_frames))
25702573
print("======================")
25712574

2572-
self.clear_context()
2573-
25742575
return response
25752576

25762577
async def resolve_async(self, event: Mapping[str, Any], context: LambdaContext) -> dict[str, Any]:
@@ -2621,16 +2622,17 @@ def lambda_handler(event, context):
26212622
BaseRouter.current_event = self._to_proxy_event(cast(dict, event))
26222623
BaseRouter.lambda_context = context
26232624

2624-
response = self._build_response(await self._resolve_async())
2625+
try:
2626+
response = self._build_response(await self._resolve_async())
2627+
finally:
2628+
self.clear_context()
26252629

26262630
if self._debug:
26272631
print("\nProcessed Middlewares:")
26282632
print("======================")
26292633
print("\n".join(self.processed_stack_frames))
26302634
print("======================")
26312635

2632-
self.clear_context()
2633-
26342636
return response
26352637

26362638
def _build_response(self, response_builder: ResponseBuilder) -> dict[str, Any]:
@@ -3401,20 +3403,16 @@ def _build_response(self, response_builder: ResponseBuilder) -> dict[str, Any]:
34013403
try:
34023404
self._validate_response_size(response)
34033405
except ResponseSizeExceededError as exc:
3404-
try:
3405-
# Resolved responses retain their route, including not-found and preflight responses.
3406-
handled_response = self._call_exception_handler(exc, cast(Route, response_builder.route))
3407-
if handled_response is None:
3408-
raise
3409-
3410-
handled_response.response = cast(Response, self._to_response(handled_response.response))
3411-
response = super()._build_response(handled_response)
3412-
# Validate once more without invoking an exception handler recursively.
3413-
self._validate_response_size(response)
3414-
except Exception:
3415-
self.clear_context()
3406+
# Resolved responses retain their route, including not-found and preflight responses.
3407+
handled_response = self._call_exception_handler(exc, cast(Route, response_builder.route))
3408+
if handled_response is None:
34163409
raise
34173410

3411+
handled_response.response = cast(Response, self._to_response(handled_response.response))
3412+
response = super()._build_response(handled_response)
3413+
# Validate once more without invoking an exception handler recursively.
3414+
self._validate_response_size(response)
3415+
34183416
return response
34193417

34203418
@staticmethod

‎tests/functional/auth_alpha/jwt/integrations/test_middleware.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,7 @@ def public():
8484
event["headers"] = {"authorization": "Bearer " + issue_token()}
8585
with pytest.raises(RuntimeError, match="handler failed"):
8686
app.resolve(event, {})
87-
assert "claims" not in app.context
88-
assert app.context["application_value"] == "preserved"
87+
assert app.context == {}
8988

9089
event["headers"] = {}
9190
public_event = copy.deepcopy(event)

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,6 +1892,23 @@ def my_path():
18921892
assert app.context == {}
18931893

18941894

1895+
def test_route_context_is_cleared_when_handler_raises():
1896+
# GIVEN a route that raises an exception without a registered exception handler
1897+
app = APIGatewayRestResolver()
1898+
app.append_context(is_admin=True)
1899+
1900+
@app.get("/my/path")
1901+
def my_path():
1902+
raise ValueError("boom")
1903+
1904+
# WHEN event resolution kicks in
1905+
with pytest.raises(ValueError, match="boom"):
1906+
app.resolve(LOAD_GW_EVENT, {})
1907+
1908+
# THEN context should be cleared even though the route raised
1909+
assert app.context == {}
1910+
1911+
18951912
def test_router_has_access_to_app_context(json_dump):
18961913
# GIVEN a Router with registered routes
18971914
app = ApiGatewayResolver()

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,24 @@ def handler(counter_id: str, request: Request):
321321
assert call_count == 3
322322

323323

324+
def test_request_is_not_reused_after_unhandled_exception():
325+
"""A handler raising must not leave its Request cached for the next invocation."""
326+
app = APIGatewayRestResolver()
327+
received: list[Request] = []
328+
329+
@app.get("/counters/<counter_id>")
330+
def handler(counter_id: str, request: Request):
331+
received.append(request)
332+
raise ValueError(counter_id)
333+
334+
for i in range(2):
335+
event = _make_rest_event(f"/counters/{i}", path_parameters={"counter_id": str(i)})
336+
with pytest.raises(ValueError):
337+
app(event, {})
338+
339+
assert [req.path_parameters for req in received] == [{"counter_id": "0"}, {"counter_id": "1"}]
340+
341+
324342
# ---------------------------------------------------------------------------
325343
# RuntimeError when accessed outside of request resolution
326344
# ---------------------------------------------------------------------------

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,22 @@ async def get_lambda():
456456
# THEN the context is cleared after resolution
457457
assert app.context == {}
458458

459+
def test_resolve_async_clears_context_when_handler_raises(self, public_resolver_and_event):
460+
# GIVEN an async handler that raises without a registered exception handler
461+
app, event, path = public_resolver_and_event
462+
463+
@app.get(path)
464+
async def get_lambda():
465+
app.append_context(custom_key="value")
466+
raise ValueError("boom")
467+
468+
# WHEN calling resolve_async
469+
with pytest.raises(ValueError, match="boom"):
470+
asyncio.run(app.resolve_async(event, MockLambdaContext()))
471+
472+
# THEN the context is still cleared
473+
assert app.context == {}
474+
459475
def test_resolve_async_not_found(self, public_resolver_and_event):
460476
# GIVEN no matching route
461477
app, event, _path = public_resolver_and_event

0 commit comments

Comments
 (0)