Repository navigation
Decode compressed request bodies and upstream responses - #319
Merged
Merged
Conversation
Clients may send a request body with `Content-Encoding: gzip`, `deflate` or `zstd`; the gateway only ever read the raw bytes and handed them to the JSON parser, so such a request failed on every route. Upstreams that answer compressed (some do even without `Accept-Encoding`) reached the client with the compressed bytes and a `content-encoding` header the client may not understand, and the gateway's own readers (usage, guards, plugins, the session log) saw garbage. Inbound: `server::intake` decodes the body right after reading it, on every gateway route, and removes `Content-Encoding` from the request, so everything downstream (parsing, conversion, passthrough, the Codex backend's own zstd re-compression) sees an ordinary uncompressed request and nothing about the client's encoding reaches the upstream. The size limit applies to the decoded size with a bounded reader: a few hundred bytes that inflate to more than the cap are refused with 413 like an oversized plain body, regardless of what the compressed data or `Content-Length` claim. An unknown encoding is refused before the body is read; a body that does not decode is a 400 in the client's format. Outbound: the new `inflate` module wraps the upstream response body in a streaming decoder (gzip/deflate/zstd) and drops `content-encoding` and the stale `content-length`. It is layered *outside* the traffic meter, not delegated to reqwest's decompression features: the traffic counters report wire bytes, and enabling reqwest's gzip/zstd would silently turn them into decoded bytes (the existing guard test keeps standing). SSE is decoded frame by frame, so streaming still arrives event by event. `br` is not decoded (no decoder in the dependency tree) and passes through as before. The traffic end-to-end test now expects the zstd answer to reach the client decoded while the received-byte count stays the wire size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Merged
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.
What
Content-Encoding: gzip,deflateorzstdis decoded inserver::intakeright after it is read, on every gateway route (messages, chat completions, responses, Gemini, count_tokens, passthrough). The header is removed from the request, so everything downstream sees an ordinary uncompressed request and nothing about the client's encoding reaches the upstream (the Codex backend path keeps its own zstd compression of the outgoing body).max + 1bytes); a small compressed body that inflates past the 256 MiB cap is refused with 413 like an oversized plain body, whatever the compressed data orContent-Lengthclaims. (There is no 2 MiB rule any more: Core#295 removed axum's default extractor limit; the 256 MiB cap inintakeis the only one, and its semantics are unchanged.)br, stacked encodings) is refused with 400 before the body is read; a body that does not decode is a 400 in the client's format.tw_gateway::inflatemodule wraps the upstream body in a streaming decoder (gzip/deflate/zstd) and dropscontent-encodingand the stalecontent-length. SSE is decoded frame by frame, so streaming still arrives event by event.bris not decoded (no decoder in the tree) and passes through as before.content-encoding.Why not reqwest's
gzip/zstdfeaturesThe traffic counters (
tw_gateway::traffic, shipped with Lite 2026.10.12) report wire bytes and are layered on the raw reqwest body; the module has a guard test (the_client_does_not_decompress) for exactly this. Enabling reqwest's automatic decompression would silently turnreceived_bytesinto decoded bytes. The decoder is therefore the gateway's own layer, applied outside the meter inhop::dispatch. The gateway still does not sendAccept-Encoding.Tests
server::intakeunit tests: gzip/deflate/zstd decode, decoded-over-limit → 413TooLarge, corrupt/truncated → 400, unknown and stacked encodings refused without reading the body,identity.inflateunit tests: frame-by-frame gzip SSE, zstd/deflate whole bodies, unknown encoding left alone, corrupt stream errors.tests/compressed.rsend to end: gzip body on the passthrough route, zstd body on a converted route (chat → Anthropic), gzipcount_tokens, corrupt body → 400 in the client's format with nothing reaching the upstream,brrefused, upstream gzip JSON reaches the client decoded withoutcontent-encoding, upstream gzip SSE reaches the client decoded with the first event arriving before the upstream has finished sending.tests/traffic.rs: the zstd answer now reaches the client decoded whilereceived_bytesstays the wire size (expectation updated; byte counting unchanged).crates/tw-api/msg-codes.txtregenerated.Verified locally:
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy cargo test -p tw-gateway(all green),cargo test --workspace(all green after regenerating the manifest).New messages
gw.request.unsupported_encoding(400): "The request body is encoded as {encoding}; the gateway decodes only gzip, deflate and zstd."gw.request.bad_encoding(400): "The request body could not be decoded as {encoding}: {detail}"gw.request.decoded_body_over_limit(413): "The request body is over the {max}-byte limit once decoded."No protocol change.
🤖 Generated with Claude Code