Skip to content

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

Description

@Zcehtro

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

Description

When CCR converts a stream:true client request into a buffered stream:false upstream request so it can
handle headroom_retrieve server-side, the next request on that session aborts inside the proxy between
outbound_headers and beta_header_merge, and the client receives HTTP 200 with an empty body. The proxy
never opens a connection to api.anthropic.com for that request.

There are two defects here:

  1. The abort itself. The request dies silently after the CCR buffered round-trip on the same session.
  2. The reporting. The failure emits no ERROR line at all in proxy.log, and answers 200 rather
    than a 5xx. A failure that returns success and logs nothing took several sessions to localise.

Correlation in a single proxy.log: 6 CCR buffered conversions, 6 empty-200 deaths, perfectly paired in
both directions across three separate client sessions.
No conversion without a death, no death without a
conversion.

The trigger appears to be headroom_retrieve being present in the tool list at all, not any particular
prompt. Note the proxy itself injects that tool
(event=tool_injection_decision ... decision=inject_sticky_replay tool_definition_bytes_count=464), so
removing the headroom MCP server client-side does not avoid it.

To Reproduce

Steps to reproduce the behavior:

  1. Install headroom: uv tool install headroom-ai (version 0.35.0).

  2. Start the proxy standalone, default transforms, CCR enabled:

    headroom proxy --port 8787
  3. Run a Claude Code client through it:

    headroom wrap claude

    (this injects ANTHROPIC_BASE_URL=http://127.0.0.1:8787 per-process)

  4. Work a long session until the proxy logs a CCR buffered conversion:

    CCR: stream:true request has headroom_retrieve available; using buffered stream:false upstream request for server-side retrieval handling
    
  5. The next request on that session dies. The client prints the error below and the session must be
    exited.

Observed on requests that are not small: five of the six deaths landed at msgs=35, the sixth at msgs=140
with content_length=284080. Never on small requests.

To localise a death in proxy.log, grep for the signature:

PERF model=\S+ msgs=\d+ tok_before=0 tok_after=0 .* transforms=none

That pattern combined with no event=outbound_request matched exactly the six deaths and nothing else.

Expected Behavior

Either the request is forwarded upstream normally, or — if the proxy cannot process it — it returns a 5xx
with a body, and logs an ERROR line naming the failure.

Actual Behavior

The proxy returns status=200 in 17 ms with an empty body, having never opened an upstream connection, and
writes no ERROR line. The client cannot parse the response and drops the connection.

Code Sample

No Python API involved — this is the headroom proxy CLI in front of a Claude Code client.

# Not applicable. Reproduced via CLI:
#   headroom proxy --port 8787
#   headroom wrap claude
# The failing request is generated by Claude Code (claude-cli/2.1.232), not by user code.

Error Output

Client side (Claude Code):

API Error: API returned an empty or malformed response (HTTP 200) — check for a proxy or gateway intercepting the request

A subagent in the same session died with Agent terminated early due to an API error.

Proxy side — the entire log footprint of the failing request hr_1786680242_000147
(2026-08-13 23:04:02, proxy.log lines 6067–6071), client 127.0.0.1:61084, user-agent: claude-cli/2.1.232,
POST /v1/messages?beta=true, model=claude-opus-5, msgs=140, content_length=284080:

event=proxy_inbound_request  ... content_length=284080
event=outbound_headers forwarder=anthropic_messages stripped_count=1
PERF msgs=140 tok_before=0 tok_after=0 tok_saved=0 opt_ms=13 total_ms=13 tok_out=0 ttfb_ms=0 transforms=none client=claude-code
STAGE_TIMINGS "compression_first_stage": null, "memory_context": null, "upstream_connect": null, "upstream_first_byte": null, "total_pre_upstream": 14.97
event=proxy_inbound_response status=200 duration_ms=17.31

A healthy request on the same proxy logs outbound_headersbeta_header_merge → pipeline →
tool_injection_decisioncache_breakpointsoutbound_request. This one stops after outbound_headers.
upstream_connect: null and compression_first_stage: null place the bail-out after read_request_json +
deep_copy and before compression — roughly 15 ms in. That is the step that reads per-session sticky
beta / sticky tool state.

The immediately preceding request hr_1786680217_000146 (23:03:37, total_ms=24796) succeeded, and is
the CCR conversion:

CCR: stream:true request has headroom_retrieve available; using buffered stream:false upstream request for server-side retrieval handling
CACHE-BUST: expected_cached=98,632 actual_read=0 tokens_lost=98,632 tokens_saved=718
CACHE-MISS-ATTRIBUTION: reason=prefix_change idle=25s ttl=300s prefix_changed=True ttl_exceeded=False

The whole log file contains zero ERROR lines across its entire life. The only WARNINGs are upstream
429s, the Kompress/Magika startup notices, and Could not parse output_tokens from SSE, estimating 28 from 1144 bytes.

Environment

  • Headroom version: 0.35.0 (installed via uv as headroom-ai)
  • Python version: 3.14.6 (system interpreter; the uv tool environment may pin its own)
  • OS: Windows 11 Pro 10.0.26200
  • LLM Provider: Anthropic (api.anthropic.com, model=claude-opus-5)
  • Client: Claude Code, claude-cli/2.1.232
  • Proxy invocation: headroom proxy --port 8787, standalone, no config file under ~/.headroom,
    default transforms

Additional Context

Paired line numbers, single proxy.log, three separate client sessions:

CCR buffered conversion empty-200 death gap (lines)
1838 1848 +10
1918 1927 +9
1946 1955 +9
2235 2275 +40
2554 2594 +40
6027 6069 +42

Other CCR: lines (workspace unresolved, Proactively expanded N context(s),
skipping proactive expansion append) are common and harmless — only the stream:true → buffered
stream:false conversion pairs with a death.

Ruled out on our side:

  • Double-bound proxy port — verified single instance,
    17432 (launcher) → 20032 → 24620 (python, owns port 8787).
  • Version drift — installed headroom-ai matches the reported 0.35.0.
  • Client-side hooks — they never touch the HTTP path; a blocked hook becomes ordinary tool_result content
    client-side, before any API call.

Wrong levers, for the record, in case they help narrow the code path:

  • --no-ccr-proactive-expansion does not help — the conversion fires on headroom_retrieve being
    available, not on expansion occurring.
  • --no-ccr avoids it but costs lossy compression with no recovery path.
  • --lossless looks like the correct workaround: no CCR retrieval marker is emitted, so no
    headroom_retrieve is needed and the buffered stream:false path never runs. Not yet confirmed by a long
    session at the time of writing.

Log excerpt available. proxy.log lines 5996–6106 verbatim with original line numbers, 47 KB — covers
the last three healthy requests, the CCR/cache-bust predecessor, the failure, and two minutes of following
silence. authorization / x-api-key / sk-ant-* redacted. Happy to attach on request.

Note for maintainers: proxy.log carries verbatim prompt and tool-call content elsewhere in the file, so
it cannot be shared wholesale.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions