Skip to content

Decode compressed request/response bodies so exchanges aren't dropped#89

Merged
bgmcmullen merged 1 commit into
masterfrom
gzip-responses
Jun 8, 2026
Merged

Decode compressed request/response bodies so exchanges aren't dropped#89
bgmcmullen merged 1 commit into
masterfrom
gzip-responses

Conversation

@bgmcmullen

Copy link
Copy Markdown
Contributor

Decode compressed bodies before caching

The AI-gateway proxy is a pass-through, so a captured body carries whatever
content-encoding the upstream (or client) applied. The recorder stringified
those bytes directly as UTF-8, so any gzip / brotli / deflate exchange landed in
the cache as an undecodable body.

Why this matters

The garbled body isn't just cosmetic — it makes the row unparseable downstream.
The claude/codex projectors run parseMaybeJson(request_body), and on a
compressed blob that fails, so they bail with projector_skip and return
undefined. The result: the entire exchange disappears from projected
output
— messages went completely missing, with only a projector_skip warn
(reason: unparseable_request_body) left as a trace.

What changed

  • New decodeBody() reverses the content-encoding before stringifying,
    applied to both request and response bodies.
  • Supports gzip/x-gzip, br, and deflate (conformant zlib decoder first,
    then raw DEFLATE for servers that omit the zlib header).
  • Handles stacked encodings, decoding in reverse of the order applied; tokens
    parsed case-insensitively, identity dropped.
  • Captures the upstream content-encoding at response start, before header
    redaction
    , so decoding still works when an operator redacts that header.

Failure behavior

Best-effort, so a decode problem never drops an exchange:
The garbled body isn't just cosmetic — it makes the row unparseable downstream.
The claude/codex projectors run parseMaybeJson(request_body), and on a
compressed blob that fails, so they bail with projector_skip and return
undefined. The result: the entire exchange disappears from projected
output
— messages went completely missing, with only a projector_skip warn
(reason: unparseable_request_body) left as a trace.

What changed

  • New decodeBody() reverses the content-encoding before stringifying,
    applied to both request and response bodies.
  • Supports gzip/x-gzip, br, and deflate (conformant zlib decoder first,
    then raw DEFLATE for servers that omit the zlib header).
  • Handles stacked encodings, decoding in reverse of the order applied; tokens
    parsed case-insensitively, identity dropped.
  • Captures the upstream content-encoding at response start, before header
    redaction
    , so decoding still works when an operator redacts that header.

Failure behavior

Best-effort, so a decode problem never drops an exchange:

  • empty body → ''
  • unknown codec → stop, keep what's decoded
  • corrupt / undecodable body → fall back to the raw bytes

(These edge cases still degrade to a possibly-garbled body rather than throwing —
the common compressed case is what this fixes.)

Tests

New test/plugins/ai-gateway-recorder-encoding.test.js: gzip, brotli, deflate
(zlib + raw), identity pass-through, gzip requests, case-insensitive headers,
decoding when content-encoding is redacted, and both fallback paths (unknown
codec, corrupt body).

@bgmcmullen bgmcmullen requested a review from philcunliffe June 8, 2026 17:31
@philcunliffe

Copy link
Copy Markdown
Contributor

Dual-agent review — request_changes

  • Verdict: request_changes
  • Risk class: medium
  • Auto-merge advisory: 👎 thumbs down — verdict is request_changes; needs human-gated follow-up

Advisory only: no merge was attempted. Verdict is request_changes because the change sits on a hot path (every exchange finalize()) with a medium blast radius and several minor follow-ups — not because of any blocker or major defect. Both reviewers found only minor/nit issues; the buffered-body fix itself is correct and tested (9/9 pass).

Risk capstone

Cross-reference: reviewer findings vs high-risk surfaces

Source Finding (severity, evidence) Intersects
Codex Compressed SSE bodies not decoded — fix incomplete (recorder.js:212,275; proxy.js:160) Risks: compressed SSE
Codex Array/stacked content-encoding collapsed by headerValue — minor (recorder.js:271,365,433) Risks: stacked/array encoding
Claude Stacked/multi-encoding decode path untested — minor (recorder.js:329-339) Risks: stacked/array encoding
Claude Array-valued content-encoding collapses to first value — minor (recorder.js:271-273,428-439) Risks: stacked/array encoding
Claude x-gzip codec untested — minor (recorder.js:332) Targets: decodeBody
Claude decodeBody docstring overstates unknown-codec fallback — minor (recorder.js:314-317) Targets: decodeBody
Claude Unknown-codec test doesn't pin partial-decode return — nit (test:100) Targets: decodeBody
Codex review

Fix Validations

Buffered compressed request/response bodies

  • Status: correct
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:271, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:275, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:322, test/plugins/ai-gateway-recorder-encoding.test.js:29
  • Assessment: Buffered gzip/br/deflate request and response bodies now decode before request_body / response_body are stored. This addresses the existing projector failure path where Codex returns undefined on unparseable request JSON and Claude logs unparseable_request_body.

Compressed streaming/SSE response bodies

  • Status: incomplete
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:160, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:212, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:275
  • Assessment: SSE chunks are still fed directly into SseParser before any content-encoding decode, and finalized SSE rows force response_body to null, so decodeBody() never repairs compressed streaming responses. Compressed event streams can still produce missing/empty stream_events.

Findings

2) Contract & Interface Fidelity

  • Severity: minor
  • Confidence: high
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:271, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:277, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:365, hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:433
  • Why it matters: parseEncodings() accepts string[], but both decode call sites pass through headerValue(), which collapses header arrays to the first string, so duplicate/array content-encoding values can silently skip required stacked decodes.
  • Suggested fix: Add a dedicated content-encoding accessor that returns all header values joined in order, and keep headerValue() single-valued for metadata like x-hyp-dev-run-id; add an array-form stacked encoding test.

No Finding

    1. Change Impact / Blast Radius
    1. Concurrency, Ordering & State Safety
    1. Error Handling & Resilience
    1. Security Surface
    1. Resource Lifecycle & Cleanup
    1. Release Safety
    1. Architectural Consistency
    1. Debuggability & Operability

Evidence Bundle

  • Changed hot paths: Exchange.setResponseStart(), Exchange.finalize(), decodeBody(), inflateOrRaw(), parseEncodings().
  • Impacted callers: hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:126, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:159, hypaware-core/plugins-workspace/ai-gateway/src/source.js:108, hypaware-core/plugins-workspace/codex/src/exchange-projector.js:57, hypaware-core/plugins-workspace/claude/src/projector.js:83.
  • Impacted tests: test/plugins/ai-gateway-recorder-encoding.test.js:29, test/plugins/ai-gateway-recorder-encoding.test.js:70, test/plugins/ai-gateway-recorder-encoding.test.js:86, test/plugins/ai-gateway-recorder-encoding.test.js:100, test/plugins/ai-gateway-recorder-encoding.test.js:108.
  • Unresolved uncertainty: I did not run npm test; no test covers compressed SSE parsing; no test covers stacked content-encoding represented as a header array.
Claude review

Claude review

Test run: node --test test/plugins/ai-gateway-recorder-encoding.test.js → 9 pass, 0 fail.

Five parallel reviewers (guidance-compliance, shallow-bug-scan, historical-context,
contract-&-callers, comments-&-tests) examined the change at the PR head. The fix is
sound and correctly placed: finalize() is the only proxied-body capture path, the
FinishedRow contract is unchanged (request_body/response_body stay string | null),
the claude/codex projectors that call parseMaybeJson() strictly benefit, no downstream
consumer re-decodes from content-encoding or couples body length to *_bytes, and the
pre-redaction capture of content-encoding does not reintroduce any prior redaction leak
(content-encoding was never a credential header; the captured value never reaches the row).
No blocker or major issues survived the confidence filter. Surviving minor/nit findings:

Stacked / multi-encoding decode path is untested

  • Severity: minor
  • Confidence: 90
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:329-339
  • Why it matters: Reverse-order decoding of stacked encodings is an advertised feature of this PR, yet no test exercises it, so a regression in loop direction or chaining would pass CI (single-codec is the only tested case; real AI traffic is almost always single-codec, so impact is low and it degrades to raw bytes).
  • Suggested fix: Add a test: gzipSync(brotliCompressSync(payload)) with header 'br, gzip' decodes back to payload.

Array-valued content-encoding collapses to the first value (latent + untested)

  • Severity: minor
  • Confidence: 88
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:271-273, :428-439
  • Why it matters: parseEncodings() accepts string[], but both decode call sites feed it through headerValue(), which returns only the first non-empty array element — so an array-form content-encoding (['gzip','br']) would silently skip the second decode, and the Array.isArray branch in parseEncodings is effectively dead for current callers (independently flagged by Codex, minor). Node only delivers set-cookie as an array in practice, so real-world impact is negligible.
  • Suggested fix: Either use a content-encoding accessor that joins all header values in order before parseEncodings, or drop the now-unreachable string[] branch; add an array-form test.

x-gzip codec is untested

  • Severity: minor
  • Confidence: 88
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:332
  • Why it matters: x-gzip is explicitly handled as a gzip alias but has no test, so the alias could be dropped silently.
  • Suggested fix: Add a content-encoding: 'x-gzip' case over a gzipSync body.

decodeBody docstring overstates the unknown-codec fallback

  • Severity: minor
  • Confidence: 85
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:314-317 vs :335
  • Why it matters: The docstring says an "unknown or undecodable encoding falls back to the raw bytes," but the unknown-codec branch returns current.toString('utf8') (the partially decoded buffer for a stacked case), not the original buf; only the catch branch returns true raw bytes — a future reader trusting the docstring would misjudge the contract.
  • Suggested fix: Reword to distinguish the two paths: "an unknown codec stops and keeps whatever has been decoded so far; a corrupt/undecodable body falls back to the original raw bytes."

Unknown-codec test does not exercise the partial-decode return

  • Severity: nit
  • Confidence: 82
  • Evidence: test/plugins/ai-gateway-recorder-encoding.test.js:100-106 vs hypaware-core/plugins-workspace/ai-gateway/src/recorder.js:335
  • Why it matters: The snappy test sends an unencoded body, so current === buf and the assertion cannot distinguish "stop and return partial current" from "return raw buf" — the exact contract the docstring is imprecise about goes unpinned.
  • Suggested fix: Add a stacked case gzipSync(payload) with header 'snappy, gzip' and assert the row equals the gunzipped (still not-snappy) intermediate.

Reports: .git/dual-review/pr-89

@philcunliffe

Copy link
Copy Markdown
Contributor

🧭 Decision map — where to spend your attention

Companion to the dual-review verdict. This casts no verdict — it points at the 3 forks where the author made a real choice, so you can skim the rest.

Scanned: 6 hunks across 2 files (+194/-2). Most is mechanical: the new 115-line table-style test file and JSDoc/comment additions on the new helpers. The decisions worth your eyes, in order:

1. Best-effort failure policy: a bad decode degrades to raw bytes, never drops or throws · unhappy-path policy

recorder.js:335

else return current.toString('utf8') // unknown codec — stop, keep what we have
} catch {
  return buf.toString('utf8') // undecodable — fall back to the raw bytes
}
  • Decision: On any decode failure, write a (possibly garbled) body rather than dropping the exchange — unknown codec keeps the partially-decoded buffer; a thrown error returns the original raw bytes.
  • Alternative not taken: null the body / set error / emit a metric on undecodable input so downstream ingests nothing rather than mojibake.
  • Check: Is "garbled body in the cache" actually better than "no body" here? The claude/codex projectors parseMaybeJson() and skip on failure — so a garbled fallback body re-creates the very projector_skip this PR exists to kill, silently and with no trace. Worth confirming the fallback firing is observable.

2. Capture content-encoding before header redaction · concurrency / lifecycle (ordering)

recorder.js:188

this.responseContentEncoding = headerValue(init.headers, 'content-encoding')
  • Decision: Snapshot the raw upstream encoding at response-start so decoding still works when an operator redacts content-encoding (request side reads _rawRequestHeaders for the same reason).
  • Alternative not taken: read the encoding from the already-redacted stored headers at finalize time (no extra field) — which would silently fail to decode whenever that header is redacted.
  • Check: Ordering is load-bearing — confirm setResponseStart runs before redaction and the field can't go stale on a double call. Contract is pinned by the test at ai-gateway-recorder-encoding.test.js:214 (decode succeeds, stored header stays REDACTED:).

3. deflate decodes zlib-first, then falls back to raw DEFLATE · codec policy

recorder.js:350

function inflateOrRaw(buf) {
  try { return inflateSync(buf) }
  catch { return inflateRawSync(buf) }
}
  • Decision: Accept both zlib-wrapped and headerless raw DEFLATE, since some servers omit the zlib header.
  • Alternative not taken: spec-conformant inflateSync only, or sniff the zlib header byte instead of try/catch.
  • Check: The catch is unconditional — a corrupt zlib stream can occasionally inflate as raw DEFLATE into different garbage instead of reaching the intended raw-bytes fallback. Acceptable at this frequency?

Honorable mentions (real but lower-stakes): recorder.js:329 — decode stacked encodings in reverse order (the string[] path is partly unreachable since headerValue collapses arrays); recorder.js:332 — codec set is gzip/x-gzip/br/deflate, no zstd; recorder.js:271 — bodies are decoded but content-encoding/content-length/*_bytes are left describing the compressed wire, so the stored row is header/body-inconsistent by design.

Generated by /decision-map. Advisory — directs attention, casts no verdict.

@bgmcmullen bgmcmullen merged commit 8b137ac into master Jun 8, 2026
6 checks passed
@bgmcmullen bgmcmullen deleted the gzip-responses branch June 8, 2026 19:13
bgmcmullen added a commit that referenced this pull request Jun 8, 2026
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.

2 participants