Skip to content

fix(proxy): stop cached responses replaying the producing turn's wire framing - #3024

Open
Parideboy wants to merge 2 commits into
headroomlabs-ai:mainfrom
Parideboy:fix/response-cache-replay-framing
Open

fix(proxy): stop cached responses replaying the producing turn's wire framing#3024
Parideboy wants to merge 2 commits into
headroomlabs-ai:mainfrom
Parideboy:fix/response-cache-replay-framing

Conversation

@Parideboy

Copy link
Copy Markdown
Contributor

Description

Closes #3019

A response-cache hit could hand the client an HTTP 200 that the client could not read, and nothing in the logs marked the turn as anything other than normal.

Two separate problems combine to produce the reported failure.

The unreadable 200. A cache entry stores the producing upstream's response headers verbatim. When the entry is replayed, the Anthropic handler removed only content-encoding, content-length and content-type before handing those headers to a brand-new Response. Anything else describing how that other connection framed its body rode along — most damagingly transfer-encoding: chunked. RFC 9112 §6.1 makes Transfer-Encoding override Content-Length, so the client is told to parse a plain JSON body as chunked frames, finds no valid chunk-size line, and reads an empty body out of a 200. Every other response-forwarding site in the Python proxy already strips that header; the two cache-hit sites were the only ones that did not.

How a CCR turn could put a foreign response in the cache. On the Anthropic path, cache.get is gated on not stream but cache.set was not, and the cache key has no stream component. A CCR buffered-stream conversion takes a request the client sent with stream: true, forces stream: false upstream, and — unlike every other streaming turn, which returns via _stream_response and never touches the cache — falls through to the store site. The stored reply was shaped by that forced flip plus CCR tool injection, and the key cannot distinguish it from an ordinary non-streaming reply, so a later non-streaming caller could be served a response built for a request it never made. This is why the reporters saw the failures pair with CCR activity and stop under --lossless / --no-ccr.

Why it was invisible. The cache-hit block emitted no log line at all, and the PERF line rendered no field for RequestOutcome.from_response_cache. A cache-served turn contacts no upstream, so it has no outbound_request line, no upstream stage timings, and all-zero token counters — byte-for-byte what a turn that died would look like. That is why headroom doctor reported zero failures while turns were dying.

Scope note

The header fix also lands on the OpenAI cache-hit site, which additionally never received the content-type fix from #2952. The not stream gate is added to the OpenAI store site too, where it is currently redundant — a streaming chat request returns via _stream_response long before that point — purely to state the invariant, since the Anthropic handler had exactly that shape until a buffered-CCR branch began falling through to it.

Because the strip list now lives in one shared helper, the OpenAI handler's other five forwarding sites strip the three added headers as well. That is a widening, so it is worth being explicit about: each of those sites builds a fresh fixed-length Response (or, at openai.py:6122, synthesises SSE) from response.content, so replaying the upstream's framing there was the same latent bug, just without a cache to make it outlive the request that produced it. The precedent is already in the file — openai.py:9865 passes "transfer-encoding", "connection" as extra names by hand, which is exactly the gap this PR closes centrally. That call site keeps its now-redundant arguments; removing them is a cleanup for another PR.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • Added sanitize_forwarded_response_headers to headroom/proxy/helpers.py, promoting the private helper that already lived in headroom/proxy/handlers/openai.py and extending it with the remaining wire-framing headers (transfer-encoding, connection, keep-alive). Matching is now case-insensitive; surviving headers keep their original casing. openai.py's _sanitize_forwarded_response_headers is now a thin alias so its six call sites and the Anthropic handler strip an identical set.
  • headroom/proxy/handlers/anthropic.py: the response-cache hit now sanitises through that helper (passing content-type as an extra name, preserving [BUG] Signed thinking blocks discard the buffered-CCR stream:false flip, so Claude Code gets API returned an empty or malformed response (HTTP 200) #2952) instead of three hand-rolled pop calls.
  • headroom/proxy/handlers/openai.py: the response-cache hit sanitises the same way, gains the content-type handling it was missing, and sets media_type="application/json" explicitly.
  • headroom/proxy/handlers/anthropic.py: cache.set is now gated on not stream, mirroring the read gate. stream still holds the client's original flag at that point — the buffered-CCR conversion flips body["stream"], never the local variable.
  • headroom/proxy/handlers/openai.py: the same not stream gate on its store site, as an invariant guard.
  • Both cache-hit sites now log RESPONSE-CACHE-HIT: model=… bytes=… age_s=… hits=…, following the existing CACHE-MISS-ATTRIBUTION line style.
  • headroom/proxy/outcome.py: the PERF line appends cached=1 on a response-cache hit. It is appended only on a hit, so every other PERF line is byte-identical to before and existing parsers are unaffected.
  • headroom/perf/analyzer.py: PerfRecord.from_response_cache reads that field, so headroom perf can tell a cache-served turn from a dead one. It defaults to False, so older logs still parse. PERF_RECORD_FIELDS gains the name at the end of the list, which is what headroom perf --format csv --raw uses as its column set; appending keeps every existing column at its current position. --format json --raw gains the key too.
  • tests/test_anthropic_pre_upstream_backpressure.py: its cache-hit double was a partial hand-rolled stand-in for CacheEntry carrying only a body and headers, so it broke once the hit path started reading the entry's age and hit count. It now constructs a real CacheEntry, which is what the cache actually returns.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality

Test Output

$ python -m pytest tests/test_proxy_response_cache_replay.py -q
tests\test_proxy_response_cache_replay.py .........                      [100%]
============================== 9 passed in 4.22s ==============================

# Everything that mentions PERF, the sanitiser, cache.set, PerfRecord or
# response_headers, plus the whole proxy suite.
$ python -m pytest tests/test_proxy/ tests/test_proxy_compression_headers.py \
    tests/test_agent_savings.py tests/test_anthropic_pre_upstream_backpressure.py \
    tests/test_backend_nonstreaming_cache_metrics.py tests/test_backend_streaming_cache_metrics.py \
    tests/test_ccr_buffered_stream_signed_thinking.py tests/test_cli_perf_format.py \
    tests/test_codex_ws_compression_scheduler.py tests/test_handler_outcome_tag_invariant.py \
    tests/test_openai_codex_ws_lifecycle.py tests/test_provider_codex_images.py \
    tests/test_proxy_handlers_batch.py tests/test_proxy_passthrough_transient_retry.py \
    tests/test_proxy_response_cache_replay.py tests/test_proxy_semantic_cache_key.py \
    tests/test_proxy_streaming_request_logger.py tests/test_request_outcome.py \
    tests/test_savings_tool_search_aggregation.py -q
================== 555 passed, 1 skipped in 88.60s (0:01:28) ==================

# Full suite, 16 workers. See "Real Behavior Proof" below for how every
# failure here was traced to a pre-existing failure or a parallelism flake.
$ python -m pytest tests scripts/tests -n 16 -q -p no:randomly --timeout=300
83 failed, 10493 passed, 657 skipped, 80 errors in 437.00s (0:07:17)

$ ruff check .
All checks passed!

$ ruff format --check <the 7 changed files>
7 files already formatted

$ python -m mypy headroom --ignore-missing-imports --python-version 3.13
Found 12 errors in 3 files (checked 520 source files)
# All 12 are pre-existing MCP-SDK/tomllib drift in release_version.py,
# ccr/mcp_server.py and memory/mcp_server.py; identical count before and
# after this change, none in the files it touches.

Real Behavior Proof

  • Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff 0.16.2, branch based on upstream/main at 2d88e31a.
  • Exact command / steps: Two experiments. (1) Revert-and-rerun: I reverted both fixes in place (dropped the three framing headers from FRAMING_RESPONSE_HEADERS, restored cache.set to if self.cache and response.status_code == 200 and resp_json is not None:), ran python -m pytest tests/test_proxy_response_cache_replay.py -q, then restored the fixes and re-ran. (2) Regression sweep: ran the full suite on this branch, then checked out upstream/main into a second worktree and re-ran, in that worktree, exactly the tests that failed here and not there.
  • Observed result: With the fixes reverted, 5 of 9 new tests fail and reproduce both halves of the bug. test_buffered_ccr_turn_does_not_write_the_response_cache fails with AssertionError: Expected mock to not have been awaited. Awaited 1 times. — a turn the client sent as stream: true really does reach cache.set through the buffered-CCR branch. test_cache_hit_replays_a_body_the_client_can_actually_read fails with AssertionError: assert 'transfer-encoding' not in {'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'request-id': ..., 'content-length': '228', ...} — the replayed 200 carries the producing turn's chunked framing alongside a fresh content-length, which is the exact framing conflict a client cannot parse. With the fixes restored, all 9 pass, the replayed body arrives intact as application/json, and the run logs both RESPONSE-CACHE-HIT and a PERF … cached=1 line. The full suite on this branch gives 83 failed, 10493 passed, 657 skipped, 80 errors; 33 of those failures were not in my baseline list, so I ran those 33 in the upstream/main worktree and 20 failed there identically (Windows-specific: sqlite:///C:\… path handling, private-directory permissions, fsync, ONNX thread caps, serena config discovery). Re-running the remaining 13 serially on this branch gave 1 failed, 25 passed — the other 12 were xdist parallelism flakes, including all four tests/test_proxy/test_anthropic_ccr_deferred_injection.py tests, which are the only ones in this change's blast radius and which pass serially. The one real serial failure, tests/test_savings_ledger_offload.py::test_concurrent_requests_all_land_their_events (AssertionError: a concurrent append was lost / assert 23 == 24), fails the same way on upstream/main run serially. The 80 errors are dashboard-template collection errors unrelated to the proxy. Net: no failure attributable to this change.
  • Not tested: I could not reproduce against live upstream traffic, so I have not confirmed which upstream in the reporters' setups emits transfer-encoding: chunked. Anthropic direct is HTTP/2, where the header is forbidden, but any HTTP/1.1 hop (corporate proxy, third-party gateway, local relay) reintroduces it. I have also not measured whether the not stream gate reduces the cache hit rate in practice; by construction it can only drop entries that were unsafe to serve. A reporter running unmodified 0.35.0 with headroom proxy --no-cache would confirm the cache path is the one involved, and that flag is a lighter workaround than --lossless or --no-ccr because it keeps CCR and compression enabled.

Runtime Rollout Safety

  • Rollout-managed feature(s): none — this is a correctness fix on the always-on response-cache path (cache_enabled defaults to True).
  • Minimum rollout channel: stable.
  • Stable/default behavior changed: yes, in four ways. Replayed cached responses no longer carry the producing upstream's framing headers (or server, on the Anthropic side). Forwarded responses on the OpenAI handler's other five sanitiser call sites no longer carry transfer-encoding, connection or keep-alive either, since the strip list is now shared; all five build a fixed-length response from response.content, so none of them could legitimately replay that framing. A turn whose client asked for stream: true no longer writes the response cache on the Anthropic path. PERF lines gain a trailing cached=1 on a response-cache hit only; all other PERF lines are unchanged.
  • Kill switch / disable path: headroom proxy --no-cache disables the response cache entirely and bypasses every path this PR touches.
  • Unsafe override required: no.
  • Qualification impact: low. No public API, config key, CLI flag or wire format changes. Two additive output changes: the cached=1 PERF field, which _parse_kv already handles the same way it handles the existing trailing client= field, and a from_response_cache column appended to headroom perf --format csv --raw (plus the matching key in --format json --raw). Anything consuming that CSV positionally keeps working because the column is last; anything reading it by name is unaffected.
  • Rollback path: revert this commit. It is self-contained with no migration, no persisted state and no schema change; cache entries written before or after behave identically on read.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit CHANGELOG.md — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)

Additional Notes

Documentation is marked N/A: no user-facing surface changes, and the new cached= PERF field is additive and self-describing.

Relationship to nearby open PRs, since several touch adjacent code:

mypy headroom --ignore-missing-imports reports 12 pre-existing errors in headroom/release_version.py, headroom/ccr/mcp_server.py and headroom/memory/mcp_server.py from MCP SDK version drift in my local environment. None are in the files this PR touches, and the count is identical before and after the change.

… framing

A response-cache hit rebuilt the client's response from the cached entry's
stored headers, dropping only content-encoding, content-length and
content-type. Everything else described how the *producing* upstream framed
its body on a different connection, and transfer-encoding: chunked is the
damaging one: RFC 9112 6.1 makes Transfer-Encoding override Content-Length,
so the client parses a plain JSON body as chunked frames, finds no valid
chunk-size line, and reads an empty body out of an HTTP 200.

Move the OpenAI handler's private sanitiser into headroom/proxy/helpers.py,
add the framing headers it was missing (transfer-encoding, connection,
keep-alive), and use it at both cache-hit sites so they strip the same set as
every other forwarding site. One OpenAI call site already passed
transfer-encoding and connection by hand; that gap is now closed centrally.

Also gate the Anthropic cache.set on the client's original stream flag,
mirroring the read side. The cache key has no stream component, and CCR's
buffered-stream conversion is the one path that reaches the store site with a
client stream:true turn: it forces stream:false upstream to buffer and rewrite
the reply, then falls through past the streaming return. The stored body was
shaped by that flip plus CCR tool injection, so serving it to a later
non-streaming caller answers a request that caller never made.

Finally, make the hit visible. A cache-served turn contacts no upstream, so it
has no outbound_request line, no upstream stage timings and all-zero token
counters, which is byte-for-byte what a dead turn looks like. Log
RESPONSE-CACHE-HIT and append cached=1 to the PERF line (only on a hit, so
existing parsers are unaffected); headroom perf reads it back.

The new PerfRecord field is appended to PERF_RECORD_FIELDS as well, since
headroom perf --format csv --raw uses that list as its DictWriter column set;
appending keeps every existing column in its current position. And the
backpressure test's cache-hit double was a partial stand-in for CacheEntry
carrying only a body and headers, which broke once the hit path started
reading the entry's age and hit count, so it now builds a real CacheEntry.

Closes headroomlabs-ai#3019

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Aug 14, 2026
@codecov-commenter

codecov-commenter commented Aug 14, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the latest merged head across both provider cache paths, the shared header policy, and the PERF parser/export contract. The key invariant is now symmetric: a client stream: true request cannot populate a cache that is only read by non-streaming requests, including the buffered-CCR fallthrough. Rebuilding the response after stripping stale content/framing metadata is also correct for decoded response.content, and the additive cached=1 parser path remains backward-compatible.

The focused replay suite passes locally on the exact head (9/9), including poisoned framing, buffered CCR, cache-hit logging, and raw PERF parsing. No blocking correctness findings.

Non-blocking follow-up: if this sanitizer becomes the general hop-by-hop boundary, consider also stripping headers named by the incoming Connection field (plus the remaining RFC hop-by-hop names). That is broader hardening and is not required for the reported stale transfer-encoding failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: ready for review Pull request body is complete and the author marked it ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Empty HTTP 200 returned after CCR buffered-stream conversion (proxy never contacts upstream, logs no error)

3 participants