feat(app-api): gzip JSON responses, with SSE excluded and un-buffered - #1133
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nothing compressed app-api responses before this. CloudFront can't do it for us: the
/api/*behaviour is deliberatelycompress: falseso the edge never buffers atext/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
StreamSafeGZipMiddlewareis defined by what it refuses to touch.The trap is not where you'd expect
The pinned
starlette==1.3.1already excludes SSE:Stock
GZipMiddlewareskipstext/event-streamby content type and skips any body that already carries aContent-Encoding. A bareadd_middleware(GZipMiddleware)would not have gzipped the chat stream.It would still have broken something. Both Starlette responders withhold
http.response.startuntil the firsthttp.response.bodyarrives — they can't know whether they'll need to setContent-Encodinguntil 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:GZipMiddlewareStreamSafeGZipMiddlewareIdentical 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:241picks 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=6rather 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 rewritesLocationon 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 upstreamcontent-typebefore 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
Managed-CachingDisabledhasEnableAcceptEncodingGzip/Brotlibothfalse, andManaged-AllViewerExceptHostHeaderisallExcept: [host]. AWS docs for exactly this case: "When caching compressed objects is disabled for both compression formats, CloudFront treats theAccept-Encodingheader 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.Content-Encoding. "When a response from an origin includes theContent-Encodingheader, CloudFront doesn't compress the object… CloudFront sends the response to the viewer." WithCACHING_DISABLEDit's a pure proxy anyway.apis.app_api.main:appon uvicorn: headers at 5 ms, 8 frames 250 ms apart, 148 B each, un-coalesced, noContent-Encoding.Not verified
No real Bedrock chat turn through
POST /invocations. Thedev-aiSSO 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-previewdoesn't exist on this branch — nosheetroute anywhere inapp_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