diff --git a/backend/src/apis/app_api/main.py b/backend/src/apis/app_api/main.py index 2755a487..6ed58ca2 100644 --- a/backend/src/apis/app_api/main.py +++ b/backend/src/apis/app_api/main.py @@ -160,8 +160,10 @@ async def lifespan(app: FastAPI): # Starlette `add_middleware` prepends, so the LAST-added middleware is # outermost. Request-side order is therefore the reverse of the call order # below: -# request: SessionRefresh → CSRF → AgentCoreContext → CORS → router -# response: router → CORS → AgentCoreContext → CSRF → SessionRefresh +# request: ProxiedRedirect → GZip → SessionRefresh → CSRF → +# AgentCoreContext → CORS → router +# response: router → CORS → AgentCoreContext → CSRF → SessionRefresh → +# GZip → ProxiedRedirect # This is the order we need: SessionRefresh has to populate # `state.bff_session` before CSRF reads it. from apis.shared.middleware.csrf import CSRFMiddleware @@ -171,6 +173,29 @@ async def lifespan(app: FastAPI): app.add_middleware(SessionRefreshMiddleware) logger.info("Added BFF session-refresh + CSRF middlewares (dormant until cookie present)") +# gzip the JSON surface. Nothing compressed app-api responses before this: +# CloudFront's `/api/*` behaviour is deliberately `compress: false` so the +# edge never buffers a `text/event-stream`, which leaves compression to the +# origin — the only layer that knows a response's content type rather than +# guessing from its path. Measured on this repo's own payload shapes at +# `compresslevel=6`: ~3.0x on a conversation-history response, ~3.9x on a +# 5,000-row spreadsheet-shaped one. Level 9 (Starlette's default) buys 3% +# more for 4x the CPU on the large case, so 6 — zlib's own default — it is. +# +# `StreamSafeGZipMiddleware` passes `text/event-stream` and already-encoded +# bodies through untouched *and un-buffered*; see its module docstring for +# why the second half of that matters on the chat path. +# +# Sits one layer inside ProxiedRedirect so it compresses everything the app +# emits — including the error bodies CSRF and SessionRefresh return — while +# leaving ProxiedRedirect genuinely outermost. The two never collide: +# ProxiedRedirect reads and rewrites only `Location`, on responses (3xx) +# whose bodies are empty or below the compression threshold anyway. +from apis.shared.middleware.compression import StreamSafeGZipMiddleware + +app.add_middleware(StreamSafeGZipMiddleware, minimum_size=500, compresslevel=6) +logger.info("Added gzip compression middleware (SSE and pre-encoded bodies excluded)") + # Outermost middleware: repair `Location` headers on redirects this app # generates for itself (Starlette's `redirect_slashes`, chiefly). Behind # CloudFront those come out as `http://api./` — the internal diff --git a/backend/src/apis/inference_api/main.py b/backend/src/apis/inference_api/main.py index aad7824c..4ff6314d 100644 --- a/backend/src/apis/inference_api/main.py +++ b/backend/src/apis/inference_api/main.py @@ -29,7 +29,6 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware from contextlib import asynccontextmanager import logging @@ -118,14 +117,31 @@ async def lifespan(app: FastAPI): register_aws_client_error_handler(app) logger.info("Registered AWS ClientError handler") -# Add GZip compression middleware for SSE streams -# Compresses responses over 1KB, reducing bandwidth by 50-70% +# Compress responses over 1KB. Despite what this block used to claim, it is +# emphatically *not* "for SSE streams": Starlette excludes `text/event-stream` +# from compression by content type, so on the one route this service exists to +# serve — the `/invocations` SSE turn — it compresses nothing at all. +# +# What stock `GZipMiddleware` did do on that route is withhold +# `http.response.start` until the first body chunk, because until then it can't +# know whether it will need to set `Content-Encoding`. On an SSE turn the first +# chunk is the model's first token, so the response headers were arriving behind +# the agent's entire thinking time — measured locally at 2.0s of pure delay on a +# turn that stalls 2.0s before its first event, with or without `Accept-Encoding: +# gzip`. app-api's chat proxy reads this response's `content-type` before it can +# open its own stream to the SPA, so that delay was propagating all the way to +# the browser. +# +# `StreamSafeGZipMiddleware` keeps the compression and forwards an excluded +# response's headers immediately; see its module docstring. +from apis.shared.middleware.compression import StreamSafeGZipMiddleware + app.add_middleware( - GZipMiddleware, + StreamSafeGZipMiddleware, minimum_size=1000, # Only compress responses > 1KB compresslevel=6 # Balance between speed and compression ratio (1-9) ) -logger.info("Added GZip middleware for response compression") +logger.info("Added gzip compression middleware (SSE and pre-encoded bodies excluded)") # Bridge AgentCore Runtime headers (WorkloadAccessToken, OAuth2CallbackUrl, # session ID) into BedrockAgentCoreContext so downstream code can look up diff --git a/backend/src/apis/shared/middleware/compression.py b/backend/src/apis/shared/middleware/compression.py new file mode 100644 index 00000000..46911ff5 --- /dev/null +++ b/backend/src/apis/shared/middleware/compression.py @@ -0,0 +1,164 @@ +"""StreamSafeGZipMiddleware — gzip JSON responses without touching SSE. + +Nothing compressed app-api responses before this. CloudFront can't do it for +us: the `/api/*` behaviour is deliberately `compress: false` +(`spa-distribution-construct.ts`) precisely so CloudFront never buffers a +`text/event-stream`, and that decision stands — compression belongs at the +origin, where the response's own content type is known, not at an edge that +has to guess from a path pattern. + +The payoff is on the JSON surface, which is most of what app-api serves; the +risk is entirely on the SSE surface, which is the product's main path (the +chat proxy in `app_api/chat/proxy_routes.py`, plus assistants and +`chat/converse_routes.py`). So this middleware is defined by what it refuses +to touch. + +**Two things are passed straight through, untouched:** + +1. Responses whose `Content-Type` is in :data:`EXCLUDED_CONTENT_TYPES` — + `text/event-stream` first and foremost, plus payload types that are + already compressed on the wire (zip archives, raster images, media, + web fonts), where gzip costs CPU and returns nothing. +2. Responses that already carry a `Content-Encoding`, whatever it is. That + header is the origin saying "this body is already encoded"; re-encoding + it would produce a body no client can read. + +Starlette's own `GZipMiddleware` skips both of those as of its +`DEFAULT_EXCLUDED_CONTENT_TYPES`/`content_encoding_set` checks, and this +class reuses that machinery rather than reimplementing it. What it adds is +the part Starlette does not do: **it sends `http.response.start` eagerly.** + +Starlette's responders withhold the response-start message until the first +`http.response.body` arrives, because until then they can't know whether +they'll need to set `Content-Encoding` and drop `Content-Length`. That is +correct for a body they might compress, and wrong for one they've already +decided not to. On an SSE turn the first body chunk is the model's first +token — seconds away, or up to `_SSE_KEEPALIVE_SECONDS` if the turn opens +with a silent tool call — and today those response headers go out +immediately. Buffering them until the first event would delay every client's +"stream is open" transition behind the agent's thinking time, on the one +path in this service least worth adding a layer to (the same reasoning that +made `ProxiedRedirectMiddleware` raw ASGI rather than `BaseHTTPMiddleware`). + +So: as soon as the response-start message identifies an excluded response, +this forwards it and flips into pure passthrough for the rest of the +exchange. Everything else — threshold, `Vary`, streaming compression, +`http.response.pathsend` — is Starlette's, unmodified. + +Non-HTTP scopes (the voice WebSocket proxy) never reach a responder at all. +""" + +from __future__ import annotations + +from starlette.datastructures import Headers +from starlette.middleware.gzip import ( + DEFAULT_EXCLUDED_CONTENT_TYPES, + GZipMiddleware, + GZipResponder, + IdentityResponder, +) +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +#: Content-type prefixes this middleware never compresses. +#: +#: `text/event-stream` is the correctness entry — gzipping an SSE stream +#: delays or breaks event flushes. It comes from Starlette's own default so +#: the two can't drift; the rest are efficiency entries, payload types that +#: arrive already compressed and would only burn CPU for ~0% gain. Matched +#: with `str.startswith`, so a charset parameter (`text/event-stream; +#: charset=utf-8`, which Starlette appends to every `text/*` media type) +#: still matches. +#: +#: Deliberately *not* excluded: `application/pdf` and `image/svg+xml`, both +#: of which compress usefully and both of which CloudFront itself lists as +#: compressible. +EXCLUDED_CONTENT_TYPES: tuple[str, ...] = ( + *DEFAULT_EXCLUDED_CONTENT_TYPES, + "application/gzip", + "application/x-7z-compressed", + "application/x-bzip2", + "application/x-gzip", + "application/x-zip-compressed", + "application/zip", + "audio/", + "font/woff", + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "video/", +) + + +def is_excluded(headers: Headers) -> bool: + """Whether this response must be passed through uncompressed. + + True for an already-encoded body and for any content type in + :data:`EXCLUDED_CONTENT_TYPES`. A response with no `Content-Type` at all + is not excluded — Starlette's size threshold already covers the empty + bodies that case usually means. + """ + if "content-encoding" in headers: + return True + return headers.get("content-type", "").startswith(EXCLUDED_CONTENT_TYPES) + + +class _EagerPassthroughMixin: + """Forward an excluded response's start message without buffering it. + + Mixed in ahead of Starlette's responders so `super()` still owns every + response this does *not* claim. + """ + + #: Class-level default; flipped on the instance by the first + #: `http.response.start` that turns out to be excluded. + _passthrough = False + + async def send_with_compression(self, message: Message) -> None: + if not self._passthrough: + if message["type"] != "http.response.start": + await super().send_with_compression(message) + return + if not is_excluded(Headers(raw=message["headers"])): + await super().send_with_compression(message) + return + # Excluded: nothing downstream will alter the headers, so the + # client can have them now rather than when the first chunk + # lands. `started` keeps the base class consistent in case + # anything else consults it. + self._passthrough = True + self.started = True + await self.send(message) + + +class _IdentityPassthroughResponder(_EagerPassthroughMixin, IdentityResponder): + """Non-gzip clients: adds `Vary`, compresses nothing.""" + + +class _GZipPassthroughResponder(_EagerPassthroughMixin, GZipResponder): + """gzip-capable clients: compresses everything not excluded.""" + + +class StreamSafeGZipMiddleware(GZipMiddleware): + """`GZipMiddleware` that never buffers an excluded response. + + Same constructor and same behaviour for compressible responses; see the + module docstring for what changes and why. + """ + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + # WebSockets (voice mode) and lifespan: not ours. + await self.app(scope, receive, send) + return + + responder: ASGIApp + if "gzip" in Headers(scope=scope).get("Accept-Encoding", ""): + responder = _GZipPassthroughResponder( + self.app, self.minimum_size, compresslevel=self.compresslevel + ) + else: + responder = _IdentityPassthroughResponder(self.app, self.minimum_size) + + await responder(scope, receive, send) diff --git a/backend/tests/apis/app_api/test_middleware_stack.py b/backend/tests/apis/app_api/test_middleware_stack.py new file mode 100644 index 00000000..b82a4b21 --- /dev/null +++ b/backend/tests/apis/app_api/test_middleware_stack.py @@ -0,0 +1,74 @@ +"""The app-api middleware stack is ordered on purpose — lock the order down. + +`main.py` documents the intended request-side order: + + ProxiedRedirect → GZip → SessionRefresh → CSRF → AgentCoreContext + → CORS → router + +Starlette's `add_middleware` prepends, so that order is the reverse of the +call order in the module and easy to break by moving a call. Two placements +in particular carry a reason: + +* ProxiedRedirect stays outermost, as its own docstring requires — it has to + see the final response headers. +* GZip sits directly inside it, so it compresses everything the app emits + (including the error bodies CSRF and SessionRefresh return) while leaving + ProxiedRedirect's `Location` rewriting untouched. +""" + +from __future__ import annotations + +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from apis.app_api.main import app +from apis.shared.middleware.agentcore_context import AgentCoreContextMiddleware +from apis.shared.middleware.compression import StreamSafeGZipMiddleware +from apis.shared.middleware.csrf import CSRFMiddleware +from apis.shared.middleware.proxied_redirect import ( + FORWARDED_PREFIX_HEADER, + ProxiedRedirectMiddleware, +) +from apis.shared.middleware.session_refresh import SessionRefreshMiddleware + +#: Outermost first — `app.user_middleware` is in request order. +EXPECTED_ORDER = [ + ProxiedRedirectMiddleware, + StreamSafeGZipMiddleware, + SessionRefreshMiddleware, + CSRFMiddleware, + AgentCoreContextMiddleware, + CORSMiddleware, +] + + +def test_middleware_order_matches_the_documented_stack() -> None: + assert [m.cls for m in app.user_middleware] == EXPECTED_ORDER + + +def test_compression_is_configured_for_json_not_for_cpu_burn() -> None: + gzip_middleware = next( + m for m in app.user_middleware if m.cls is StreamSafeGZipMiddleware + ) + + # Level 9 (Starlette's default) costs ~4x the CPU of level 6 for ~3% + # more compression on this repo's largest payloads. + assert gzip_middleware.kwargs["compresslevel"] == 6 + assert gzip_middleware.kwargs["minimum_size"] == 500 + + +def test_redirect_rewriting_survives_the_compression_layer() -> None: + """ProxiedRedirect still owns `Location` with GZip underneath it.""" + client = TestClient(app, follow_redirects=False) + + response = client.get( + "/agents/", + headers={ + "Host": "api.dev.boisestate.ai", + "Accept-Encoding": "gzip", + FORWARDED_PREFIX_HEADER: "/api", + }, + ) + + assert response.status_code == 307 + assert response.headers["location"] == "/api/agents" diff --git a/backend/tests/apis/inference_api/test_middleware_stack.py b/backend/tests/apis/inference_api/test_middleware_stack.py new file mode 100644 index 00000000..b5802379 --- /dev/null +++ b/backend/tests/apis/inference_api/test_middleware_stack.py @@ -0,0 +1,38 @@ +"""inference-api must not go back to stock `GZipMiddleware`. + +This service exists to serve one route — the `/invocations` SSE turn — and +Starlette's stock gzip middleware withholds `http.response.start` until the +first body chunk arrives. On an SSE turn that chunk is the model's first +token, so the response headers landed behind the agent's whole thinking time, +with or without `Accept-Encoding: gzip`. app-api's chat proxy reads this +response's `content-type` before it can open its own stream to the SPA, so +the delay reached the browser. + +The swap to `StreamSafeGZipMiddleware` is easy to undo by accident — the two +have the same constructor and compress identically — so it is pinned here. +""" + +from __future__ import annotations + +from starlette.middleware.gzip import GZipMiddleware + +from apis.inference_api.main import app +from apis.shared.middleware.compression import StreamSafeGZipMiddleware + + +def test_compression_is_the_stream_safe_variant() -> None: + installed = [m.cls for m in app.user_middleware] + + assert StreamSafeGZipMiddleware in installed + # `is not` rather than membership: the stream-safe class *subclasses* + # `GZipMiddleware`, so an `in` check would pass either way. + assert not any(cls is GZipMiddleware for cls in installed) + + +def test_compression_settings_are_unchanged_by_the_swap() -> None: + gzip_middleware = next( + m for m in app.user_middleware if m.cls is StreamSafeGZipMiddleware + ) + + assert gzip_middleware.kwargs["minimum_size"] == 1000 + assert gzip_middleware.kwargs["compresslevel"] == 6 diff --git a/backend/tests/apis/shared/middleware/test_compression_middleware.py b/backend/tests/apis/shared/middleware/test_compression_middleware.py new file mode 100644 index 00000000..a8edc03f --- /dev/null +++ b/backend/tests/apis/shared/middleware/test_compression_middleware.py @@ -0,0 +1,293 @@ +"""Tests for StreamSafeGZipMiddleware. + +app-api compresses its JSON surface, and the entire risk of doing so lands on +the one surface it must never touch: `text/event-stream`. Compressing an SSE +stream — or merely *buffering its response headers* — degrades or breaks the +chat path, which is the product's main path. + +So most of what follows is about what the middleware refuses to do. The +sharpest test is `test_excluded_response_start_is_sent_before_first_chunk`: +Starlette's stock responders withhold `http.response.start` until the first +body message arrives, which on a chat turn is the model's first token. That +would put the agent's thinking time in front of every client's "stream is +open" transition, so this middleware forwards the start message the moment +it can see the response is excluded. +""" + +from __future__ import annotations + +import asyncio +import gzip +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.middleware.gzip import DEFAULT_EXCLUDED_CONTENT_TYPES +from starlette.responses import Response, StreamingResponse +from starlette.types import Message, Receive, Scope, Send + +from apis.shared.middleware.compression import ( + EXCLUDED_CONTENT_TYPES, + StreamSafeGZipMiddleware, +) + +GZIP = {"Accept-Encoding": "gzip"} + +#: Comfortably over the 500-byte floor these tests configure, and +#: compressible enough that the result is smaller than the input. +BIG_PAYLOAD = {"rows": [{"name": f"row {i}", "status": "OK"} for i in range(200)]} + + +@pytest.fixture +def app() -> FastAPI: + app = FastAPI() + app.add_middleware(StreamSafeGZipMiddleware, minimum_size=500, compresslevel=6) + + @app.get("/big") + def big() -> dict: + return BIG_PAYLOAD + + @app.get("/small") + def small() -> dict: + return {"ok": True} + + @app.get("/sse") + def sse() -> StreamingResponse: + async def events(): + for i in range(50): + yield f"data: {json.dumps({'i': i, 'text': 'x' * 100})}\n\n".encode() + + return StreamingResponse(events(), media_type="text/event-stream") + + @app.get("/zip") + def archive() -> Response: + return Response(b"PK\x03\x04" + b"\x00" * 4000, media_type="application/zip") + + @app.get("/pre-encoded") + def pre_encoded() -> Response: + # A handler that compressed its own body — e.g. a proxied upstream + # response relayed with its encoding intact. + return Response( + gzip.compress(b"already encoded " * 500), + media_type="application/json", + headers={"Content-Encoding": "gzip"}, + ) + + return app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +# --- the JSON surface: this is the payoff --------------------------------- + + +def test_large_json_is_compressed_and_round_trips(client: TestClient) -> None: + raw = client.get("/big", headers={"Accept-Encoding": "identity"}) + compressed = client.get("/big", headers=GZIP) + + assert compressed.headers["content-encoding"] == "gzip" + # httpx decodes transparently; the decoded body must be byte-identical. + assert compressed.json() == BIG_PAYLOAD + assert int(compressed.headers["content-length"]) < len(raw.content) + assert "accept-encoding" in compressed.headers["vary"].lower() + + +def test_small_json_is_left_alone(client: TestClient) -> None: + response = client.get("/small", headers=GZIP) + + assert "content-encoding" not in response.headers + assert response.json() == {"ok": True} + + +def test_client_without_gzip_gets_an_uncompressed_body(client: TestClient) -> None: + response = client.get("/big", headers={"Accept-Encoding": "identity"}) + + assert "content-encoding" not in response.headers + assert response.json() == BIG_PAYLOAD + # Still varies, so a cache can't hand this body to a gzip client. + assert "accept-encoding" in response.headers["vary"].lower() + + +# --- the SSE surface: this is the risk ------------------------------------ + + +def test_sse_is_never_compressed(client: TestClient) -> None: + response = client.get("/sse", headers=GZIP) + + assert response.status_code == 200 + assert "content-encoding" not in response.headers + assert response.headers["content-type"].startswith("text/event-stream") + # Every frame intact, in order, and readable as plain text. + frames = [f for f in response.text.split("\n\n") if f] + assert len(frames) == 50 + assert json.loads(frames[0].removeprefix("data: "))["i"] == 0 + assert json.loads(frames[-1].removeprefix("data: "))["i"] == 49 + + +async def _drive(middleware: StreamSafeGZipMiddleware, scope: Scope) -> list[Message]: + """Run `middleware` over `scope`, returning the messages it sent.""" + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + async def receive() -> Message: # pragma: no cover - never awaited + return {"type": "http.disconnect"} + + await middleware(scope, receive, send) + return sent + + +@pytest.mark.parametrize("accept_encoding", [b"gzip", b"identity"]) +def test_excluded_response_start_is_sent_before_first_chunk( + accept_encoding: bytes, +) -> None: + """Headers must not wait on the agent's first token. + + The stub below opens an SSE response and then stalls, exactly like a turn + that spends its first seconds on a tool call. Stock `GZipMiddleware` + would hold `http.response.start` for the whole stall; this must not. + """ + first_chunk_released = asyncio.Event() + + async def stalling_sse(scope: Scope, receive: Receive, send: Send) -> None: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/event-stream; charset=utf-8")], + } + ) + await first_chunk_released.wait() + await send({"type": "http.response.body", "body": b"data: hi\n\n"}) + + middleware = StreamSafeGZipMiddleware(stalling_sse, minimum_size=500) + scope: Scope = { + "type": "http", + "headers": [(b"accept-encoding", accept_encoding)], + } + + async def scenario() -> list[Message]: + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + async def receive() -> Message: # pragma: no cover - never awaited + return {"type": "http.disconnect"} + + task = asyncio.create_task(middleware(scope, receive, send)) + # Let the stub reach its stall, then look at what the client has. + await asyncio.sleep(0) + await asyncio.sleep(0) + headers_before_body = list(sent) + + first_chunk_released.set() + await task + return headers_before_body + + headers_before_body = asyncio.run(scenario()) + + assert [m["type"] for m in headers_before_body] == ["http.response.start"] + assert not any( + name.lower() == b"content-encoding" + for name, _ in headers_before_body[0]["headers"] + ) + + +def test_compressible_streaming_response_still_withholds_start() -> None: + """The eager path is scoped to excluded responses only. + + A compressible stream *must* keep buffering its start message — that is + where `Content-Encoding` gets added and `Content-Length` dropped. + """ + released = asyncio.Event() + + async def stalling_json(scope: Scope, receive: Receive, send: Send) -> None: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/json")], + } + ) + await released.wait() + await send( + {"type": "http.response.body", "body": b"x" * 2000, "more_body": True} + ) + await send({"type": "http.response.body", "body": b""}) + + middleware = StreamSafeGZipMiddleware(stalling_json, minimum_size=500) + scope: Scope = {"type": "http", "headers": [(b"accept-encoding", b"gzip")]} + + async def scenario() -> tuple[list[Message], list[Message]]: + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + async def receive() -> Message: # pragma: no cover - never awaited + return {"type": "http.disconnect"} + + task = asyncio.create_task(middleware(scope, receive, send)) + await asyncio.sleep(0) + await asyncio.sleep(0) + before = list(sent) + + released.set() + await task + return before, sent + + before, after = asyncio.run(scenario()) + + assert before == [] + assert after[0]["type"] == "http.response.start" + assert (b"content-encoding", b"gzip") in after[0]["headers"] + + +# --- other pass-throughs --------------------------------------------------- + + +def test_pre_encoded_body_is_not_re_encoded(client: TestClient) -> None: + response = client.get("/pre-encoded", headers=GZIP) + + assert response.headers["content-encoding"] == "gzip" + # One layer of gzip, not two: httpx strips exactly one. + assert response.content == b"already encoded " * 500 + + +def test_already_compressed_payload_types_are_skipped(client: TestClient) -> None: + response = client.get("/zip", headers=GZIP) + + assert "content-encoding" not in response.headers + assert response.content.startswith(b"PK\x03\x04") + + +def test_non_http_scopes_are_passed_straight_through() -> None: + """Voice mode is a WebSocket proxy; it must never meet a responder.""" + seen: list[Scope] = [] + + async def inner(scope: Scope, receive: Receive, send: Send) -> None: + seen.append(scope) + + middleware = StreamSafeGZipMiddleware(inner) + scope: Scope = {"type": "websocket", "headers": [(b"accept-encoding", b"gzip")]} + + asyncio.run(_drive(middleware, scope)) + + assert seen == [scope] + + +def test_starlette_exclusions_are_carried_forward() -> None: + """Drift guard on the pinned Starlette. + + `text/event-stream` is excluded here *because* it is excluded upstream — + if a version bump ever renames or empties that constant, this fails + rather than silently un-excluding SSE. + """ + assert "text/event-stream" in DEFAULT_EXCLUDED_CONTENT_TYPES + assert set(DEFAULT_EXCLUDED_CONTENT_TYPES) <= set(EXCLUDED_CONTENT_TYPES) diff --git a/infrastructure/lib/constructs/spa/spa-distribution-construct.ts b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts index 7007a520..33486ad8 100644 --- a/infrastructure/lib/constructs/spa/spa-distribution-construct.ts +++ b/infrastructure/lib/constructs/spa/spa-distribution-construct.ts @@ -49,6 +49,18 @@ export interface SpaDistributionConstructProps { * ALL_VIEWER_EXCEPT_HOST_HEADER pass cookies + CSRF + auth headers * untouched. compress=false to preserve `text/event-stream`. * + * compress=false does NOT mean `/api/*` responses travel uncompressed: + * it means CloudFront doesn't compress *for* us. app-api gzips its own + * JSON (`apis/shared/middleware/compression.py`), where the response's + * content type is known rather than guessed from a path pattern, and + * CloudFront passes an origin's `Content-Encoding` straight through. + * Accept-Encoding reaches the origin because CACHING_DISABLED leaves + * EnableAcceptEncodingGzip/Brotli off — with both off, CloudFront + * treats Accept-Encoding as an ordinary header, and + * ALL_VIEWER_EXCEPT_HOST_HEADER forwards it verbatim. Turning + * compress=true on would put the edge back in front of the SSE stream + * and gain nothing the origin isn't already doing. + * * Security headers: * - X-Content-Type-Options, X-Frame-Options=DENY (default-deny iframe * embedding), Referrer-Policy=strict-origin-when-cross-origin, HSTS