Skip to content

Commit 7503cf4

Browse files
fix(event_handler): convert null body to empty string in ALBResolver to avoid HTTP 502 (#4683)
* fix(event_handler): convert null body to empty string in ALBResolver to avoid HTTP 502 * docs(event_handler): prepare content to help emphasize auto-serialization Signed-off-by: heitorlessa <[email protected]> * docs: move auto-serialization into a new subsection * Adding 2e2 tests --------- Signed-off-by: heitorlessa <[email protected]> Co-authored-by: Leandro Damascena <[email protected]>
1 parent 5d437cd commit 7503cf4

File tree

8 files changed

+165
-21
lines changed

8 files changed

+165
-21
lines changed

aws_lambda_powertools/event_handler/api_gateway.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
cast,
3131
)
3232

33+
from typing_extensions import override
34+
3335
from aws_lambda_powertools.event_handler import content_types
3436
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
3537
from aws_lambda_powertools.event_handler.openapi.constants import DEFAULT_API_VERSION, DEFAULT_OPENAPI_VERSION
@@ -2652,3 +2654,24 @@ def __init__(
26522654
def _get_base_path(self) -> str:
26532655
# ALB doesn't have a stage variable, so we just return an empty string
26542656
return ""
2657+
2658+
@override
2659+
def _to_response(self, result: Union[Dict, Tuple, Response]) -> Response:
2660+
"""Convert the route's result to a Response
2661+
2662+
ALB requires a non-null body otherwise it converts as HTTP 5xx
2663+
2664+
3 main result types are supported:
2665+
2666+
- Dict[str, Any]: Rest api response with just the Dict to json stringify and content-type is set to
2667+
application/json
2668+
- Tuple[dict, int]: Same dict handling as above but with the option of including a status code
2669+
- Response: returned as is, and allows for more flexibility
2670+
"""
2671+
2672+
# NOTE: Minor override for early return on Response with null body for ALB
2673+
if isinstance(result, Response) and result.body is None:
2674+
logger.debug("ALB doesn't allow None responses; converting to empty string")
2675+
result.body = ""
2676+
2677+
return super()._to_response(result)

docs/core/event_handler/api_gateway.md

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,45 @@ This is the sample infrastructure for API Gateway and Lambda Function URLs we ar
5151

5252
### Event Resolvers
5353

54-
Before you decorate your functions to handle a given path and HTTP method(s), you need to initialize a resolver.
54+
Before you decorate your functions to handle a given path and HTTP method(s), you need to initialize a resolver. A resolver will handle request resolution, including [one or more routers](#split-routes-with-router), and give you access to the current event via typed properties.
5555

56-
A resolver will handle request resolution, including [one or more routers](#split-routes-with-router), and give you access to the current event via typed properties.
56+
By default, we will use `APIGatewayRestResolver` throughout the documentation. You can use any of the following:
5757

58-
For resolvers, we provide: `APIGatewayRestResolver`, `APIGatewayHttpResolver`, `ALBResolver`, `LambdaFunctionUrlResolver`, and `VPCLatticeResolver`. From here on, we will default to `APIGatewayRestResolver` across examples.
58+
| Resolver | AWS service |
59+
| ------------------------------------------------------- | -------------------------------------- |
60+
| **[`APIGatewayRestResolver`](#api-gateway-rest-api)** | Amazon API Gateway REST API |
61+
| **[`APIGatewayHttpResolver`](#api-gateway-http-api)** | Amazon API Gateway HTTP API |
62+
| **[`ALBResolver`](#application-load-balancer)** | Amazon Application Load Balancer (ALB) |
63+
| **[`LambdaFunctionUrlResolver`](#lambda-function-url)** | AWS Lambda Function URL |
64+
| **[`VPCLatticeResolver`](#vpc-lattice)** | Amazon VPC Lattice |
5965

60-
???+ info "Auto-serialization"
61-
We serialize `Dict` responses as JSON, trim whitespace for compact responses, set content-type to `application/json`, and
62-
return a 200 OK HTTP status. You can optionally set a different HTTP status code as the second argument of the tuple:
66+
#### Response auto-serialization
67+
68+
> Want full control of the response, headers and status code? [Read about `Response` object here](#fine-grained-responses).
69+
70+
For your convenience, we automatically perform these if you return a dictionary response:
71+
72+
1. Auto-serialize `dictionary` responses to JSON and trim it
73+
2. Include the response under each resolver's equivalent of a `body`
74+
3. Set `Content-Type` to `application/json`
75+
4. Set `status_code` to 200 (OK)
76+
77+
=== "getting_started_resolvers_response_serialization.py"
78+
79+
```python hl_lines="9"
80+
--8<-- "examples/event_handler_rest/src/getting_started_resolvers_response_serialization.py"
81+
```
82+
83+
1. This dictionary will be serialized, trimmed, and included under the `body` key
84+
85+
=== "getting_started_resolvers_response_serialization_output.json"
86+
87+
```json hl_lines="8"
88+
--8<-- "examples/event_handler_rest/src/getting_started_resolvers_response_serialization_output.json"
89+
```
90+
91+
??? info "Coming from Flask? We also support tuple response"
92+
You can optionally set a different HTTP status code as the second argument of the tuple.
6393

6494
```python hl_lines="15 16"
6595
--8<-- "examples/event_handler_rest/src/getting_started_return_tuple.py"
@@ -462,16 +492,16 @@ In the following example, we use a new `Header` OpenAPI type to add [one out of
462492

463493
With data validation enabled, we natively support serializing the following data types to JSON:
464494

465-
| Data type | Serialized type |
466-
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
467-
| **Pydantic models** | `dict` |
468-
| **Python Dataclasses** | `dict` |
469-
| **Enum** | Enum values |
470-
| **Datetime** | Datetime ISO format string |
471-
| **Decimal** | `int` if no exponent, or `float` |
472-
| **Path** | `str` |
473-
| **UUID** | `str` |
474-
| **Set** | `list` |
495+
| Data type | Serialized type |
496+
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
497+
| **Pydantic models** | `dict` |
498+
| **Python Dataclasses** | `dict` |
499+
| **Enum** | Enum values |
500+
| **Datetime** | Datetime ISO format string |
501+
| **Decimal** | `int` if no exponent, or `float` |
502+
| **Path** | `str` |
503+
| **UUID** | `str` |
504+
| **Set** | `list` |
475505
| **Python primitives** _(dict, string, sequences, numbers, booleans)_ | [Python's default JSON serializable types](https://docs.python.org/3/library/json.html#encoders-and-decoders){target="_blank" rel="nofollow"} |
476506

477507
???+ info "See [custom serializer section](#custom-serializer) for bringing your own."
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
2+
from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext
3+
4+
app = APIGatewayRestResolver()
5+
6+
7+
@app.get("/ping")
8+
def ping():
9+
return {"message": "pong"} # (1)!
10+
11+
12+
def lambda_handler(event: dict, context: LambdaContext) -> dict:
13+
return app.resolve(event, context)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"statusCode": 200,
3+
"multiValueHeaders": {
4+
"Content-Type": [
5+
"application/json"
6+
]
7+
},
8+
"body": "{'message':'pong'}",
9+
"isBase64Encoded": false
10+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from aws_lambda_powertools.event_handler import (
2+
ALBResolver,
3+
Response,
4+
)
5+
6+
app = ALBResolver()
7+
8+
9+
@app.get("/todos_with_no_body")
10+
def todos():
11+
return Response(
12+
status_code=200,
13+
)
14+
15+
16+
def lambda_handler(event, context):
17+
return app.resolve(event, context)

tests/e2e/event_handler/infrastructure.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Dict, Optional
1+
from typing import Dict, List, Optional
22

33
from aws_cdk import CfnOutput
44
from aws_cdk import aws_apigateway as apigwv1
@@ -17,12 +17,12 @@ class EventHandlerStack(BaseInfrastructure):
1717
def create_resources(self):
1818
functions = self.create_lambda_functions()
1919

20-
self._create_alb(function=functions["AlbHandler"])
20+
self._create_alb(function=[functions["AlbHandler"], functions["AlbHandlerWithBodyNone"]])
2121
self._create_api_gateway_rest(function=functions["ApiGatewayRestHandler"])
2222
self._create_api_gateway_http(function=functions["ApiGatewayHttpHandler"])
2323
self._create_lambda_function_url(function=functions["LambdaFunctionUrlHandler"])
2424

25-
def _create_alb(self, function: Function):
25+
def _create_alb(self, function: List[Function]):
2626
vpc = ec2.Vpc.from_lookup(
2727
self.stack,
2828
"VPC",
@@ -33,15 +33,19 @@ def _create_alb(self, function: Function):
3333
alb = elbv2.ApplicationLoadBalancer(self.stack, "ALB", vpc=vpc, internet_facing=True)
3434
CfnOutput(self.stack, "ALBDnsName", value=alb.load_balancer_dns_name)
3535

36-
self._create_alb_listener(alb=alb, name="Basic", port=80, function=function)
36+
# Function with Body
37+
self._create_alb_listener(alb=alb, name="Basic", port=80, function=function[0])
3738
self._create_alb_listener(
3839
alb=alb,
3940
name="MultiValueHeader",
4041
port=8080,
41-
function=function,
42+
function=function[0],
4243
attributes={"lambda.multi_value_headers.enabled": "true"},
4344
)
4445

46+
# Function without Body
47+
self._create_alb_listener(alb=alb, name="BasicWithoutBody", port=8081, function=function[1])
48+
4549
def _create_alb_listener(
4650
self,
4751
alb: elbv2.ApplicationLoadBalancer,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pytest
2+
from requests import Request
3+
4+
from tests.e2e.utils import data_fetcher
5+
from tests.e2e.utils.auth import build_iam_auth
6+
7+
8+
@pytest.fixture
9+
def alb_basic_without_body_listener_endpoint(infrastructure: dict) -> str:
10+
dns_name = infrastructure.get("ALBDnsName")
11+
port = infrastructure.get("ALBBasicWithoutBodyListenerPort", "")
12+
return f"http://{dns_name}:{port}"
13+
14+
15+
@pytest.mark.xdist_group(name="event_handler")
16+
def test_alb_with_body_empty(alb_basic_without_body_listener_endpoint):
17+
# GIVEN url has a trailing slash - it should behave as if there was not one
18+
url = f"{alb_basic_without_body_listener_endpoint}/todos_with_no_body"
19+
20+
# WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher
21+
response = data_fetcher.get_http_response(
22+
Request(
23+
method="GET",
24+
url=url,
25+
auth=build_iam_auth(url=url, aws_service="lambda"),
26+
),
27+
)
28+
29+
assert response.status_code == 200

tests/functional/event_handler/required_dependencies/test_api_gateway.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,3 +1820,21 @@ def static_handler() -> Response:
18201820
# THEN the static_handler should have been called, because it fully matches the path directly
18211821
response_body = json.loads(response["body"])
18221822
assert response_body["hello"] == "static"
1823+
1824+
1825+
def test_alb_empty_response_object():
1826+
# GIVEN an ALB Resolver
1827+
app = ALBResolver()
1828+
event = {"path": "/my/request", "httpMethod": "GET"}
1829+
1830+
# AND route returns a Response object with empty body
1831+
@app.get("/my/request")
1832+
def opa():
1833+
return Response(status_code=200, content_type=content_types.APPLICATION_JSON)
1834+
1835+
# WHEN calling the event handler
1836+
result = app(event, {})
1837+
1838+
# THEN body should be converted to an empty string
1839+
assert result["statusCode"] == 200
1840+
assert result["body"] == ""

0 commit comments

Comments
 (0)