From b515acd463ccb85c9f1f72a50a1c68456f5b78b4 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Fri, 30 Oct 2026 22:24:58 +0530 Subject: [PATCH 1/2] fix(event_handler): add Vary: Accept-Encoding to responses of compressed routes --- .../event_handler/api_gateway.py | 17 ++++++++++ .../required_dependencies/test_api_gateway.py | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 72b86111ac9..6dd96646502 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -792,6 +792,19 @@ def _add_cors(self, event: ResponseEventT, cors: CORSConfig): cors_headers["Vary"] = f"{existing_vary}, Origin" self.response.headers.update(cors_headers) + def _add_vary(self, field: str): + """Add a request header name to the Vary response header, keeping any value already set""" + existing_vary = self.response.headers.get("Vary") + if isinstance(existing_vary, list): + existing_vary = ", ".join(existing_vary) + if not existing_vary: + self.response.headers["Vary"] = field + return + values = {value.strip().lower() for value in existing_vary.split(",")} + # "*" already covers every request header + if field.lower() not in values and "*" not in values: + self.response.headers["Vary"] = f"{existing_vary}, {field}" + def _add_cache_control(self, cache_control: str): """Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used.""" cache_control = cache_control if self.response.status_code == 200 else "no-cache" @@ -857,6 +870,10 @@ def _route(self, event: ResponseEventT, cors: CORSConfig | None): self._add_cors(event, cors or CORSConfig()) if self.route.cache_control: self._add_cache_control(self.route.cache_control) + compression = self.response.compress if self.response.compress is not None else self.route.compress + if compression: + # The body depends on Accept-Encoding, so shared caches must not reuse it across encodings + self._add_vary("Accept-Encoding") if self._has_compression_enabled( route_compression=self.route.compress, response_compression=self.response.compress, diff --git a/tests/functional/event_handler/required_dependencies/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py index 2f21174bd97..ac5d8a65494 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -621,6 +621,40 @@ def return_text() -> Response: assert result["body"] == expected_value +@pytest.mark.parametrize( + "route_compress, response_compress, response_headers, accept_encoding, expected_vary", + [ + pytest.param(True, None, None, "gzip", ["Accept-Encoding"], id="compressed response"), + pytest.param(True, None, None, "deflate", ["Accept-Encoding"], id="uncompressed response of a compress route"), + pytest.param(False, True, None, "gzip", ["Accept-Encoding"], id="compress enabled on the response"), + pytest.param(True, None, {"Vary": "Origin"}, "gzip", ["Origin, Accept-Encoding"], id="existing Vary kept"), + pytest.param(True, None, {"Vary": "accept-encoding"}, "gzip", ["accept-encoding"], id="not added twice"), + pytest.param(True, None, {"Vary": "*"}, "gzip", ["*"], id="wildcard Vary kept as is"), + pytest.param(False, None, None, "gzip", None, id="compression not enabled"), + pytest.param(True, False, None, "gzip", None, id="compression disabled on the response"), + ], +) +def test_compress_sets_vary_accept_encoding( + route_compress: bool, + response_compress: bool | None, + response_headers: dict | None, + accept_encoding: str, + expected_vary: list[str] | None, +): + # GIVEN a route whose body depends on the request Accept-Encoding when compression is enabled + app = ApiGatewayResolver() + + @app.get("/my/path", compress=route_compress) + def return_text() -> Response: + return Response(200, content_types.TEXT_PLAIN, "Foo", headers=response_headers, compress=response_compress) + + # WHEN calling the event handler + result = app({"path": "/my/path", "httpMethod": "GET", "headers": {"Accept-Encoding": accept_encoding}}, None) + + # THEN Vary names Accept-Encoding whenever compression can change the body, so caches keep encodings apart + assert result["multiValueHeaders"].get("Vary") == expected_vary + + @pytest.mark.parametrize( "accept_encoding, expected_compressed", [ From a28d073e7bdae73a6fe5f7e6fc1998aebb3dce30 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Sat, 31 Oct 2026 17:36:05 +0000 Subject: [PATCH 2/2] refactor(event_handler): reuse the Vary helper for CORS and cover list-valued Vary CORS now goes through _add_vary too, which also stops it from emitting 'Origin, Origin' or '*, Origin' when the route already set Vary. --- aws_lambda_powertools/event_handler/api_gateway.py | 8 +++----- .../required_dependencies/test_api_gateway.py | 8 ++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 6dd96646502..ec9b6b0011c 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -785,12 +785,10 @@ def _add_cors(self, event: ResponseEventT, cors: CORSConfig): if origin is not None: cors_headers = cors.to_dict(origin) # Keep any Vary value the route already set, and add Origin to it - existing_vary = self.response.headers.get("Vary") - if isinstance(existing_vary, list): - existing_vary = ", ".join(existing_vary) - if existing_vary and "Vary" in cors_headers: - cors_headers["Vary"] = f"{existing_vary}, Origin" + vary = cors_headers.pop("Vary", None) self.response.headers.update(cors_headers) + if vary: + self._add_vary(vary) def _add_vary(self, field: str): """Add a request header name to the Vary response header, keeping any value already set""" diff --git a/tests/functional/event_handler/required_dependencies/test_api_gateway.py b/tests/functional/event_handler/required_dependencies/test_api_gateway.py index ac5d8a65494..4f8d49d2a10 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_gateway.py +++ b/tests/functional/event_handler/required_dependencies/test_api_gateway.py @@ -630,6 +630,14 @@ def return_text() -> Response: pytest.param(True, None, {"Vary": "Origin"}, "gzip", ["Origin, Accept-Encoding"], id="existing Vary kept"), pytest.param(True, None, {"Vary": "accept-encoding"}, "gzip", ["accept-encoding"], id="not added twice"), pytest.param(True, None, {"Vary": "*"}, "gzip", ["*"], id="wildcard Vary kept as is"), + pytest.param( + True, + None, + {"Vary": ["Accept", "Accept-Language"]}, + "gzip", + ["Accept, Accept-Language, Accept-Encoding"], + id="existing Vary list kept", + ), pytest.param(False, None, None, "gzip", None, id="compression not enabled"), pytest.param(True, False, None, "gzip", None, id="compression disabled on the response"), ],