Decode compressed request/response bodies so exchanges aren't dropped#89
Conversation
Dual-agent review —
|
| 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_bodyare stored. This addresses the existing projector failure path where Codex returnsundefinedon unparseable request JSON and Claude logsunparseable_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
SseParserbefore anycontent-encodingdecode, and finalized SSE rows forceresponse_bodytonull, sodecodeBody()never repairs compressed streaming responses. Compressed event streams can still produce missing/emptystream_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()acceptsstring[], but both decode call sites pass throughheaderValue(), which collapses header arrays to the first string, so duplicate/arraycontent-encodingvalues 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 likex-hyp-dev-run-id; add an array-form stacked encoding test.
No Finding
-
- Change Impact / Blast Radius
-
- Concurrency, Ordering & State Safety
-
- Error Handling & Resilience
-
- Security Surface
-
- Resource Lifecycle & Cleanup
-
- Release Safety
-
- Architectural Consistency
-
- 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 stackedcontent-encodingrepresented 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 topayload.
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()acceptsstring[], but both decode call sites feed it throughheaderValue(), which returns only the first non-empty array element — so an array-formcontent-encoding(['gzip','br']) would silently skip the second decode, and theArray.isArraybranch inparseEncodingsis effectively dead for current callers (independently flagged by Codex, minor). Node only deliversset-cookieas 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-unreachablestring[]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-gzipis 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 agzipSyncbody.
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 originalbuf; 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
snappytest sends an unencoded body, socurrent === bufand the assertion cannot distinguish "stop and return partialcurrent" from "return rawbuf" — 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
🧭 Decision map — where to spend your attentionCompanion 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 policyelse return current.toString('utf8') // unknown codec — stop, keep what we have
} catch {
return buf.toString('utf8') // undecodable — fall back to the raw bytes
}
2. Capture
|
Decode compressed bodies before caching
The AI-gateway proxy is a pass-through, so a captured body carries whatever
content-encodingthe upstream (or client) applied. The recorder stringifiedthose 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 acompressed blob that fails, so they bail with
projector_skipand returnundefined. The result: the entire exchange disappears from projectedoutput — messages went completely missing, with only a
projector_skipwarn(
reason: unparseable_request_body) left as a trace.What changed
decodeBody()reverses thecontent-encodingbefore stringifying,applied to both request and response bodies.
gzip/x-gzip,br, anddeflate(conformant zlib decoder first,then raw DEFLATE for servers that omit the zlib header).
parsed case-insensitively,
identitydropped.content-encodingat response start, before headerredaction, 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 acompressed blob that fails, so they bail with
projector_skipand returnundefined. The result: the entire exchange disappears from projectedoutput — messages went completely missing, with only a
projector_skipwarn(
reason: unparseable_request_body) left as a trace.What changed
decodeBody()reverses thecontent-encodingbefore stringifying,applied to both request and response bodies.
gzip/x-gzip,br, anddeflate(conformant zlib decoder first,then raw DEFLATE for servers that omit the zlib header).
parsed case-insensitively,
identitydropped.content-encodingat response start, before headerredaction, so decoding still works when an operator redacts that header.
Failure behavior
Best-effort, so a decode problem never drops an exchange:
''(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-encodingis redacted, and both fallback paths (unknowncodec, corrupt body).