Skip to content

fix(event_handler): convert null body to empty string in ALBResolver to avoid HTTP 502 #4683

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions aws_lambda_powertools/event_handler/api_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
cast,
)

from typing_extensions import override

from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.exceptions import NotFoundError, ServiceError
from aws_lambda_powertools.event_handler.openapi.constants import DEFAULT_API_VERSION, DEFAULT_OPENAPI_VERSION
Expand Down Expand Up @@ -2652,3 +2654,24 @@ def __init__(
def _get_base_path(self) -> str:
# ALB doesn't have a stage variable, so we just return an empty string
return ""

@override
def _to_response(self, result: Union[Dict, Tuple, Response]) -> Response:
"""Convert the route's result to a Response

ALB requires a non-null body otherwise it converts as HTTP 5xx

3 main result types are supported:

- Dict[str, Any]: Rest api response with just the Dict to json stringify and content-type is set to
application/json
- Tuple[dict, int]: Same dict handling as above but with the option of including a status code
- Response: returned as is, and allows for more flexibility
"""

# NOTE: Minor override for early return on Response with null body for ALB
if isinstance(result, Response) and result.body is None:
logger.debug("ALB doesn't allow None responses; converting to empty string")
result.body = ""

return super()._to_response(result)
62 changes: 46 additions & 16 deletions docs/core/event_handler/api_gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,45 @@ This is the sample infrastructure for API Gateway and Lambda Function URLs we ar

### Event Resolvers

Before you decorate your functions to handle a given path and HTTP method(s), you need to initialize a resolver.
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.

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.
By default, we will use `APIGatewayRestResolver` throughout the documentation. You can use any of the following:

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

???+ info "Auto-serialization"
We serialize `Dict` responses as JSON, trim whitespace for compact responses, set content-type to `application/json`, and
return a 200 OK HTTP status. You can optionally set a different HTTP status code as the second argument of the tuple:
#### Response auto-serialization

> Want full control of the response, headers and status code? [Read about `Response` object here](#fine-grained-responses).

For your convenience, we automatically perform these if you return a dictionary response:

1. Auto-serialize `dictionary` responses to JSON and trim it
2. Include the response under each resolver's equivalent of a `body`
3. Set `Content-Type` to `application/json`
4. Set `status_code` to 200 (OK)

=== "getting_started_resolvers_response_serialization.py"

```python hl_lines="9"
--8<-- "examples/event_handler_rest/src/getting_started_resolvers_response_serialization.py"
```

1. This dictionary will be serialized, trimmed, and included under the `body` key

=== "getting_started_resolvers_response_serialization_output.json"

```json hl_lines="8"
--8<-- "examples/event_handler_rest/src/getting_started_resolvers_response_serialization_output.json"
```

??? info "Coming from Flask? We also support tuple response"
You can optionally set a different HTTP status code as the second argument of the tuple.

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

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

| Data type | Serialized type |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Pydantic models** | `dict` |
| **Python Dataclasses** | `dict` |
| **Enum** | Enum values |
| **Datetime** | Datetime ISO format string |
| **Decimal** | `int` if no exponent, or `float` |
| **Path** | `str` |
| **UUID** | `str` |
| **Set** | `list` |
| Data type | Serialized type |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pydantic models** | `dict` |
| **Python Dataclasses** | `dict` |
| **Enum** | Enum values |
| **Datetime** | Datetime ISO format string |
| **Decimal** | `int` if no exponent, or `float` |
| **Path** | `str` |
| **UUID** | `str` |
| **Set** | `list` |
| **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"} |

???+ info "See [custom serializer section](#custom-serializer) for bringing your own."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext

app = APIGatewayRestResolver()


@app.get("/ping")
def ping():
return {"message": "pong"} # (1)!


def lambda_handler(event: dict, context: LambdaContext) -> dict:
return app.resolve(event, context)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"statusCode": 200,
"multiValueHeaders": {
"Content-Type": [
"application/json"
]
},
"body": "{'message':'pong'}",
"isBase64Encoded": false
}
17 changes: 17 additions & 0 deletions tests/e2e/event_handler/handlers/alb_handler_with_body_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from aws_lambda_powertools.event_handler import (
ALBResolver,
Response,
)

app = ALBResolver()


@app.get("/todos_with_no_body")
def todos():
return Response(
status_code=200,
)


def lambda_handler(event, context):
return app.resolve(event, context)
14 changes: 9 additions & 5 deletions tests/e2e/event_handler/infrastructure.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, Optional
from typing import Dict, List, Optional

from aws_cdk import CfnOutput
from aws_cdk import aws_apigateway as apigwv1
Expand All @@ -17,12 +17,12 @@ class EventHandlerStack(BaseInfrastructure):
def create_resources(self):
functions = self.create_lambda_functions()

self._create_alb(function=functions["AlbHandler"])
self._create_alb(function=[functions["AlbHandler"], functions["AlbHandlerWithBodyNone"]])
self._create_api_gateway_rest(function=functions["ApiGatewayRestHandler"])
self._create_api_gateway_http(function=functions["ApiGatewayHttpHandler"])
self._create_lambda_function_url(function=functions["LambdaFunctionUrlHandler"])

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

self._create_alb_listener(alb=alb, name="Basic", port=80, function=function)
# Function with Body
self._create_alb_listener(alb=alb, name="Basic", port=80, function=function[0])
self._create_alb_listener(
alb=alb,
name="MultiValueHeader",
port=8080,
function=function,
function=function[0],
attributes={"lambda.multi_value_headers.enabled": "true"},
)

# Function without Body
self._create_alb_listener(alb=alb, name="BasicWithoutBody", port=8081, function=function[1])

def _create_alb_listener(
self,
alb: elbv2.ApplicationLoadBalancer,
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/event_handler/test_response_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest
from requests import Request

from tests.e2e.utils import data_fetcher
from tests.e2e.utils.auth import build_iam_auth


@pytest.fixture
def alb_basic_without_body_listener_endpoint(infrastructure: dict) -> str:
dns_name = infrastructure.get("ALBDnsName")
port = infrastructure.get("ALBBasicWithoutBodyListenerPort", "")
return f"http://{dns_name}:{port}"


@pytest.mark.xdist_group(name="event_handler")
def test_alb_with_body_empty(alb_basic_without_body_listener_endpoint):
# GIVEN url has a trailing slash - it should behave as if there was not one
url = f"{alb_basic_without_body_listener_endpoint}/todos_with_no_body"

# WHEN calling an invalid URL (with trailing slash) expect HTTPError exception from data_fetcher
response = data_fetcher.get_http_response(
Request(
method="GET",
url=url,
auth=build_iam_auth(url=url, aws_service="lambda"),
),
)

assert response.status_code == 200
Original file line number Diff line number Diff line change
Expand Up @@ -1820,3 +1820,21 @@ def static_handler() -> Response:
# THEN the static_handler should have been called, because it fully matches the path directly
response_body = json.loads(response["body"])
assert response_body["hello"] == "static"


def test_alb_empty_response_object():
# GIVEN an ALB Resolver
app = ALBResolver()
event = {"path": "/my/request", "httpMethod": "GET"}

# AND route returns a Response object with empty body
@app.get("/my/request")
def opa():
return Response(status_code=200, content_type=content_types.APPLICATION_JSON)

# WHEN calling the event handler
result = app(event, {})

# THEN body should be converted to an empty string
assert result["statusCode"] == 200
assert result["body"] == ""