Skip to content

Commit 37ea59c

Browse files
feat(event_handler): add opt-in ALB response size validation (#8490)
1 parent 611c101 commit 37ea59c

5 files changed

Lines changed: 528 additions & 3 deletions

File tree

‎aws_lambda_powertools/event_handler/api_gateway.py‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import json
55
import logging
6+
import os
67
import re
78
import traceback
89
import warnings
@@ -18,7 +19,7 @@
1819

1920
from aws_lambda_powertools.event_handler import content_types
2021
from aws_lambda_powertools.event_handler.exception_handling import ExceptionHandlerManager
21-
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
22+
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ResponseSizeExceededError, ServiceError
2223
from aws_lambda_powertools.event_handler.openapi.config import OpenAPIConfig
2324
from aws_lambda_powertools.event_handler.openapi.constants import (
2425
DEFAULT_API_VERSION,
@@ -72,6 +73,7 @@
7273
_NAMED_GROUP_BOUNDARY_PATTERN = rf"(?P\1[{_SAFE_URI}{_UNSAFE_URI}\\w]+)"
7374
_ROUTE_REGEX = "^{}$"
7475
_JSON_DUMP_CALL = partial(json.dumps, separators=(",", ":"), cls=Encoder)
76+
_ALB_MAX_RESPONSE_SIZE = 1024 * 1024
7577

7678
ResponseEventT = TypeVar("ResponseEventT", bound=BaseProxyEvent)
7779
ResponseT = TypeVar("ResponseT")
@@ -2558,7 +2560,7 @@ def resolve(self, event: Mapping[str, Any], context: LambdaContext) -> dict[str,
25582560
BaseRouter.current_event = self._to_proxy_event(cast(dict, event))
25592561
BaseRouter.lambda_context = context
25602562

2561-
response = self._resolve().build(self.current_event, self._cors)
2563+
response = self._build_response(self._resolve())
25622564

25632565
# Debug print Processed Middlewares
25642566
if self._debug:
@@ -2619,7 +2621,7 @@ def lambda_handler(event, context):
26192621
BaseRouter.current_event = self._to_proxy_event(cast(dict, event))
26202622
BaseRouter.lambda_context = context
26212623

2622-
response = (await self._resolve_async()).build(self.current_event, self._cors)
2624+
response = self._build_response(await self._resolve_async())
26232625

26242626
if self._debug:
26252627
print("\nProcessed Middlewares:")
@@ -2631,6 +2633,10 @@ def lambda_handler(event, context):
26312633

26322634
return response
26332635

2636+
def _build_response(self, response_builder: ResponseBuilder) -> dict[str, Any]:
2637+
"""Build the final integration response after route and middleware processing."""
2638+
return response_builder.build(self.current_event, self._cors)
2639+
26342640
async def _resolve_async(self) -> ResponseBuilder:
26352641
method = self.current_event.http_method.upper()
26362642
path = self._remove_prefix(self.current_event.path)
@@ -3342,6 +3348,7 @@ def __init__(
33423348
response_validation_error_http_code: HTTPStatus | int | None = None,
33433349
json_body_deserializer: Callable[[str], dict] | None = None,
33443350
decode_query_parameters: bool = False,
3351+
enable_response_size_validation: bool = False,
33453352
):
33463353
"""Amazon Application Load Balancer (ALB) resolver
33473354
@@ -3368,6 +3375,10 @@ def __init__(
33683375
by default json.loads when integrating with EventSource data class
33693376
decode_query_parameters: bool | None
33703377
Enables URL-decoding of query parameters (both keys and values), by default False.
3378+
enable_response_size_validation: bool
3379+
Validate the complete serialized response against ALB's 1 MB limit, by default False.
3380+
Raises ResponseSizeExceededError when the limit is exceeded. Registered exception handlers
3381+
can return a smaller response, which is also validated.
33713382
"""
33723383
super().__init__(
33733384
cors=cors,
@@ -3379,6 +3390,42 @@ def __init__(
33793390
json_body_deserializer=json_body_deserializer,
33803391
)
33813392
self.decode_query_parameters = decode_query_parameters
3393+
self._enable_response_size_validation = enable_response_size_validation
3394+
3395+
@override
3396+
def _build_response(self, response_builder: ResponseBuilder) -> dict[str, Any]:
3397+
response = super()._build_response(response_builder)
3398+
if not self._enable_response_size_validation:
3399+
return response
3400+
3401+
try:
3402+
self._validate_response_size(response)
3403+
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()
3416+
raise
3417+
3418+
return response
3419+
3420+
@staticmethod
3421+
def _validate_response_size(response: dict[str, Any]) -> None:
3422+
# Match the Lambda Python runtime's outer JSON encoding, including its default separators.
3423+
# Managed Python 3.12+ runtimes return Unicode directly; older/custom runtimes escape it.
3424+
runtime = re.fullmatch(r"AWS_Lambda_python3\.(\d+)", os.getenv("AWS_EXECUTION_ENV", ""))
3425+
ensure_ascii = runtime is None or int(runtime[1]) < 12
3426+
actual_size = len(json.dumps(response, ensure_ascii=ensure_ascii).encode("utf-8"))
3427+
if actual_size > _ALB_MAX_RESPONSE_SIZE:
3428+
raise ResponseSizeExceededError(actual_size=actual_size, max_size=_ALB_MAX_RESPONSE_SIZE)
33823429

33833430
def _get_base_path(self) -> str:
33843431
# ALB doesn't have a stage variable, so we just return an empty string

‎aws_lambda_powertools/event_handler/exceptions.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
from http import HTTPStatus
44

55

6+
class ResponseSizeExceededError(Exception):
7+
"""The serialized Lambda response exceeds the integration's size limit."""
8+
9+
def __init__(self, *, actual_size: int, max_size: int):
10+
"""
11+
Parameters
12+
----------
13+
actual_size: int
14+
Size of the complete serialized response in bytes.
15+
max_size: int
16+
Maximum allowed serialized response size in bytes.
17+
"""
18+
self.actual_size = actual_size
19+
self.max_size = max_size
20+
super().__init__(
21+
f"Serialized response size ({actual_size} bytes) exceeds the maximum allowed size ({max_size} bytes)",
22+
)
23+
24+
625
class ServiceError(Exception):
726
"""Powertools class HTTP Service Error"""
827

‎docs/core/event_handler/api_gateway.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,22 @@ When using Amazon Application Load Balancer (ALB) to front your Lambda functions
151151
--8<-- "examples/event_handler_rest/src/getting_started_alb_api_resolver.py"
152152
```
153153

154+
##### Validate response size
155+
156+
ALB limits the complete response JSON returned by Lambda to [1 MB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html){target="_blank"}. An oversized response can produce a 502 error even when the Lambda handler finishes successfully.
157+
158+
Set `enable_response_size_validation=True` to check the response against the 1,048,576-byte limit before returning it to ALB. This option is disabled by default and applies only to `ALBResolver`, through both `resolve()` and `resolve_async()`.
159+
160+
The check includes the JSON envelope, serialized body, headers, and cookies after compression and base64 encoding. It follows the Lambda Python runtime's JSON encoding, including escaping and UTF-8 byte lengths.
161+
162+
When the limit is exceeded, `ResponseSizeExceededError` exposes `actual_size` and `max_size`, both in bytes. Without a registered exception handler, the exception propagates and fails the invocation. You can register a handler to log the sizes and return a small 500 response:
163+
164+
```python hl_lines="7 10-22" title="Validate ALB response size"
165+
--8<-- "examples/event_handler_rest/src/alb_response_size_validation.py"
166+
```
167+
168+
The handler's response is also validated. If it exceeds the limit, the exception propagates without invoking the handler again.
169+
154170
#### Lambda Function URL
155171

156172
When using [AWS Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/urls-configuration.html){target="_blank"}, you can use `LambdaFunctionUrlResolver`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from aws_lambda_powertools import Logger
2+
from aws_lambda_powertools.event_handler import ALBResolver, Response, content_types
3+
from aws_lambda_powertools.event_handler.exceptions import ResponseSizeExceededError
4+
from aws_lambda_powertools.utilities.typing import LambdaContext
5+
6+
logger = Logger()
7+
app = ALBResolver(enable_response_size_validation=True)
8+
9+
10+
@app.exception_handler(ResponseSizeExceededError)
11+
def handle_large_response(error: ResponseSizeExceededError) -> Response:
12+
logger.error(
13+
"Response exceeds the ALB limit",
14+
actual_size=error.actual_size,
15+
max_size=error.max_size,
16+
)
17+
return Response(
18+
status_code=500,
19+
content_type=content_types.APPLICATION_JSON,
20+
body={"message": "Unable to return the response"},
21+
)
22+
23+
24+
@app.get("/reports")
25+
def get_report():
26+
return {"report": "Report contents"}
27+
28+
29+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
30+
return app.resolve(event, context)

0 commit comments

Comments
 (0)