Skip to content

feat(app-api): gzip JSON responses, with SSE excluded and un-buffered - #1133

Merged
philmerrell merged 2 commits into
developfrom
claude/bold-morse-ee45cf
Sep 16, 2026
Merged

philmerrell merged 2 commits into
developfrom
claude/bold-morse-ee45cf

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Nothing compressed app-api responses before this. CloudFront can't do it for us: the /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 it from a path pattern.

The whole risk is the SSE surface, so the new StreamSafeGZipMiddleware is defined by what it refuses to touch.

The trap is not where you'd expect

The pinned starlette==1.3.1 already excludes SSE:

DEFAULT_EXCLUDED_CONTENT_TYPES = ("text/event-stream",)

Stock GZipMiddleware skips text/event-stream by content type and skips any body that already carries a Content-Encoding. A bare add_middleware(GZipMiddleware) would not have gzipped the chat stream.

It would still have broken something. Both Starlette responders withhold http.response.start until the first http.response.body arrives — they can't know whether they'll need to set Content-Encoding until they see a body. On a chat turn that chunk is the model's first token, so response headers end up behind the agent's entire thinking time. Measured against the real middleware stack on a turn that stalls 2s before its first event:

headers first chunk
stock GZipMiddleware 2011 ms 2012 ms
StreamSafeGZipMiddleware 5 ms 2008 ms

Identical without Accept-Encoding: gzip — the identity responder buffers too, so a non-gzip client got the delay as well.

What's here

A ~30-line subclass, not route scoping. Route scoping can't work: proxy_routes.py:241 picks SSE-vs-JSON from the upstream's content type at runtime, so the same route is both. Content type is the only honest signal.

It reuses Starlette's exclusion machinery (a test pins the constant, so a version bump can't silently un-exclude SSE) and adds the one thing Starlette doesn't — when the response-start message shows the response is excluded, it forwards those headers immediately and passes the rest through untouched. Also excludes already-compressed payload types (zip, raster images, media, woff): CPU for ~0% gain.

compresslevel=6 rather than Starlette's 9 — level 9 buys 3% more for 4× the CPU on a 1 MB body (89 ms → 21 ms). Measured ratios at 6: ~3.0× on a conversation-history response, ~3.9× on a 5,000-row grid, 4.9× on a 748 B catalog.

Ordering is now ProxiedRedirect → GZip → SessionRefresh → CSRF → AgentCoreContext → CORS → router. ProxiedRedirect stays outermost as its docstring requires; the two can't collide since it only rewrites Location on 3xx. Comment extended, order pinned by a test.

inference-api was already broken

It already had GZipMiddleware, commented "for SSE streams… reducing bandwidth by 50-70%". Both halves are wrong: it compresses nothing on /invocations, and it was adding the full header delay above. That delay wasn't contained there — app-api's proxy reads the upstream content-type before it can open its own stream to the SPA, so it propagated to the browser. Swapped to the shared middleware with its settings unchanged; re-measured at 5 ms.

Verification

  • Accept-Encoding reaches the origin. Read the live managed policies: Managed-CachingDisabled has EnableAcceptEncodingGzip/Brotli both false, and Managed-AllViewerExceptHostHeader is allExcept: [host]. AWS docs for exactly this case: "When caching compressed objects is disabled for both compression formats, CloudFront treats the Accept-Encoding header the same as any other HTTP header… You can include it in the headers list in a cache policy or an origin request policy." No normalization path applies.
  • CloudFront passes through the origin's Content-Encoding. "When a response from an origin includes the Content-Encoding header, CloudFront doesn't compress the object… CloudFront sends the response to the viewer." With CACHING_DISABLED it's a pure proxy anyway.
  • SSE streams incrementally. Against the real apis.app_api.main:app on uvicorn: headers at 5 ms, 8 frames 250 ms apart, 148 B each, un-coalesced, no Content-Encoding.
  • JSON compresses through the whole stack: 120,900 B → 5,302 B on the wire, byte-identical after decode.
  • Tests: 8,605 passed, 3 skipped (16 new).

Not verified

No real Bedrock chat turn through POST /invocations. The dev-ai SSO token is expired and the session couldn't re-auth, so a dev-configured app-api wouldn't boot. The probe reproduces the proxy's response shape, not its upstream. Worth one manual turn on dev after merge.

Note on the brief

GET /files/{id}/sheet-preview doesn't exist on this branch — no sheet route anywhere in app_api, nothing in the SPA referencing it. Benchmarks above are payload shapes, not that endpoint. Separately, the test baseline here is 8,592 collected, not the 8,663 quoted; the 71-test gap is pre-existing and unrelated to this change.

🤖 Generated with Claude Code

philmerrell and others added 2 commits September 16, 2026 07:57
Nothing compressed app-api responses before this. CloudFront can't do it
for us: the `/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 it from a path pattern.

The whole risk is the SSE surface (the chat proxy, assistants,
api-converse), so `StreamSafeGZipMiddleware` is defined by what it refuses
to touch. Starlette's pinned 1.3.1 already excludes `text/event-stream`
and already skips a body that arrives with a `Content-Encoding`, and this
reuses that machinery rather than reimplementing it — a test pins the
constant so a version bump can't silently un-exclude SSE.

What it adds is the part Starlette doesn't do: it sends
`http.response.start` eagerly for an excluded response. Stock responders
withhold the start message until the first body chunk, because until then
they can't know whether they'll need to set `Content-Encoding` — and on a
chat turn that chunk is the model's first token. Measured against the real
middleware stack on a turn that stalls 2s before its first event, stock
gzip delivered headers at 2011ms (identically without `Accept-Encoding:
gzip`, since the identity responder buffers too); this delivers them at
5ms with the first chunk still at 2008ms.

Also excluded: payload types that arrive already compressed (zip archives,
raster images, media, web fonts), where gzip is CPU for ~0% gain.
`compresslevel=6` rather than Starlette's 9 — level 9 buys 3% more
compression for 4x the CPU on a 1MB body. Measured ratios at 6: ~3.0x on a
conversation-history response, ~3.9x on a 5,000-row grid, 4.9x on a 748B
catalog.

Placed one layer inside ProxiedRedirect, which stays outermost as its own
docstring requires; the two can't collide, since it rewrites only
`Location` on 3xx responses whose bodies are empty or under the threshold.

Verified end to end against the real app on uvicorn: SSE headers at 5ms,
frames arriving 250ms apart un-coalesced with no `Content-Encoding`, and a
120,900B JSON response leaving as 5,302B on the wire, byte-identical after
decode. Accept-Encoding reaching the origin is confirmed from the live
managed policies — CACHING_DISABLED leaves EnableAcceptEncodingGzip and
Brotli off, so CloudFront treats it as an ordinary header and
ALL_VIEWER_EXCEPT_HOST_HEADER forwards it verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
inference-api already had `GZipMiddleware`, added with the comment "for SSE
streams… reducing bandwidth by 50-70%". Both halves are wrong. Starlette
excludes `text/event-stream` by content type, so on the one route this
service exists to serve — the `/invocations` turn — it compresses nothing.

What it did do is withhold `http.response.start` until the first body
chunk, which on an SSE turn is the model's first token. Measured locally
against the real app on a turn that stalls 2s before its first event:
headers at 2011ms with `Accept-Encoding: gzip`, 2007ms without (the
identity responder buffers too). That delay wasn't contained here —
app-api's chat proxy reads this response's `content-type` before it can
open its own stream to the SPA, so it propagated to the browser.

Swapping to `StreamSafeGZipMiddleware` keeps the compression and the
existing settings, and forwards an excluded response's headers
immediately: re-measured at 5ms, with the first chunk still at 2009ms.

The swap is easy to undo by accident — the two classes share a constructor
and compress identically — so a test pins it, checking `is not
GZipMiddleware` rather than membership, since the stream-safe class
subclasses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 5602a3f into develop Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant