diff --git a/aws_lambda_powertools/event_handler/api_gateway.py b/aws_lambda_powertools/event_handler/api_gateway.py index 72b86111ac9..ec9b6b0011c 100644 --- a/aws_lambda_powertools/event_handler/api_gateway.py +++ b/aws_lambda_powertools/event_handler/api_gateway.py @@ -785,12 +785,23 @@ 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""" + 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.""" @@ -857,6 +868,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..4f8d49d2a10 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,48 @@ 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( + 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"), + ], +) +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", [