headroom/tests/test_nonstream_sse_policy.py
Tejas Chopra 0e26fb80de
fix(proxy/anthropic): stop answering a non-streaming turn with an event stream (#3142)
## Description

Closes #3130. Unifies #3131 (@Joaovsales) and #3132 (@taiseii), which
landed within hours of each other on the same bug. Neither is redundant
— **#3131 contributed the clearest statement of the contract; #3132
contributed the reconstruction that can actually be trusted to satisfy
it.** This takes both.

A caller that sent `stream: false` was handed a `text/event-stream` body
at HTTP 200. The reply was complete — 8756 bytes, a valid upstream
`request-id` — it was simply wearing a wire format the SDK cannot parse,
so the turn was lost.

**On root cause.** #3130 says outright: *"I could not pin down why the
upstream answered a `stream`-less request with an event stream."* I
think this does. At `v0.35.0` the CCR path flips the body to `stream:
false` and never touches the client's `Accept` header — I checked the
tag and the count of Accept rewrites at that site is **zero**. So
upstream receives a self-contradicting request: *"answer as JSON"* in
the body, *"I only accept SSE"* in the headers. Both reporters (#3130,
#3140) show `server: cloudflare` / `cf-ray`, and both describe it as
intermittent — consistent with an edge honouring `Accept` under retry.
#3102 fixed that for the CCR flip; this PR moves the rewrite to the
buffered boundary **every** non-streaming request reaches, so the
client's own non-streaming retry is covered too.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

**From #3131 — the contract.** `headroom/proxy/nonstream_sse_policy.py`:
a pure module with a behaviour matrix and `should_recover_sse_reply` as
a single predicate. The three negative arms are deliberate — a streaming
caller wants SSE, a JSON content-type is already correct, a non-200
carries an upstream error the client should see verbatim.

**From #3132 — the reconstruction.** `require_complete=True` demands
`message_start`, a terminal `message_stop`, every opened block closed,
no in-band `error` event, and no delta the reconstructor cannot replay.
Anything short of that is a 502.

Three things only #3132 had, each load-bearing:

- **`index` is stripped from rebuilt content blocks.** The parser writes
it (`streaming.py:425`) and a client persists the reconstructed turn and
echoes it back — at which point Anthropic 400s with
`content.0.text.index: Extra inputs are not permitted`.
`_strip_streaming_only_content_fields` (`anthropic.py:185`) already
documents this exact failure. That inbound stripper would mask it *while
the proxy is in the path*, but the client's stored history is still
polluted.
- **SSE framing is normalized and `data:` no longer requires the
optional space.** The old `startswith("data: ")` skipped a spec-valid
stream **entirely** — zero events parsed, which is literally what the
report describes (*"0 stream events received"*).
- **Detection sniffs the body**, so a mislabeled or absent content-type
is still caught.

**Reconciled where they disagreed:**

- *Headers.* #3131 hand-rolled a framing list; this uses the established
`sanitize_forwarded_response_headers`. That already strips `connection`,
`keep-alive` and `server` alongside the content-* family — and per the
comment at `helpers.py:325`, leaving `transfer-encoding` on a rebuilt
body is what produced an empty HTTP 200 in #3019. #3131's list would
have left three of those on. #3132's `cf-*` filter is kept.
- *Detection.* The body sniff arrives as `body_is_event_stream`, so the
policy module stays pure — the sniff needs the response object and the
handler owns that.
- Dropped #3131's `json_reply_headers` and its test class; everything
else from both PRs is retained.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Integration tests pass

### Test Output

```text
tests/test_nonstream_sse_policy.py   18 passed   (from #3131)
tests/test_anthropic_buffered_sse.py 18 passed   (from #3132)
                                     36 passed

Regression sweep (-k "stream or sse or ccr or anthropic or proxy or buffered or usage"):
  2982 passed, 181 skipped, 0 failed in 153.41s

ruff check: All checks passed
ruff format --check: 527 files already formatted
```

Both contributors' suites are kept whole and both pass unmodified
against the merged implementation, which is the useful signal here —
they were written independently against different implementations.

## Real Behavior Proof

- Environment: macOS (darwin 25.4.0), Python 3.12.13, worktree off
`main`, `_core.abi3.so` copied in.
- Exact command / steps: applied #3132 as the engine, layered #3131's
policy module over it, rewired the decision site to the predicate, then
ran both suites and a 2982-test sweep concentrated on everything
touching the shared SSE parser.
- Observed result: 36/36 across both contributed suites, 2982 passed / 0
failed on the sweep. The sweep matters more than usual here —
`_parse_sse_to_response` is shared with the streaming path's usage
accounting, and `require_complete` defaults to `False` specifically so
existing callers keep the lenient reconstruction they were written
against. Nothing regressed.
- Not tested: no live upstream. I could not reproduce the upstream
answering a `stream`-less request with SSE against real
`api.anthropic.com` — that is the condition #3130 reports as
intermittent and load-dependent, and the Accept explanation above
remains a well-supported hypothesis rather than something I observed.
The fix does not depend on it: whatever the upstream returns, a caller
that did not ask for streaming is no longer handed SSE.

## Runtime Rollout Safety

- Rollout-managed feature(s): None.
- Minimum rollout channel: n/a
- Stable/default behavior changed: Yes, deliberately, in two places. A
non-streaming turn answered with SSE is now reconstructed as JSON
instead of relayed; an SSE reply that cannot be faithfully reconstructed
is now a 502 instead of an unparseable 200. Both are the point.
`require_complete` defaults to `False`, so streaming callers of the
shared parser are untouched.
- Kill switch / disable path: none by design — relaying a body the
client cannot parse has no legitimate mode.
- Unsafe override required: No.
- Qualification impact: A truncated upstream stream now surfaces as an
explicit 502 rather than a short-but-successful turn. More visible
failures, fewer silent ones.
- Rollback path: Revert the commit.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Additional Notes

If this lands, #3131 and #3132 should be closed as superseded — both
authors are credited via `Co-authored-by:` and their tests ship intact.
I would not close either before a maintainer agrees this unification is
the direction, since it discards a design decision from each.

**Wider context, not fixed here:** #3130 and #3140 both report against
**0.35.0**, and `main` already carries a stack of fixes for this symptom
class that has never shipped — #3102 (Accept), #3092, #3091, #3094,
#3101, #3069, #3084, #3124, #3134. All of them are gated behind #3067
`chore: release 0.36.0`. Every closed lookalike (#3019, #3055, #3071,
#3040, #2952) was fixed into that same unreleased window. Merging this
PR does not help either reporter until 0.36.0 ships; **cutting that
release is the higher-leverage action.**

The interim workaround for anyone on 0.35.0 is `HEADROOM_NO_CCR=1` — the
buffered flip is gated on `_has_headroom_retrieve_tool`, and `no_ccr`
stops the tool being injected, so the flip never engages. Note `headroom
wrap` has no `--no-ccr` flag in 0.35.0, so it has to be the env var.

Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: João Souto <73318835+Joaovsales@users.noreply.github.com>
Co-authored-by: taiseii <37083727+taiseii@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:45:38 -07:00

263 lines
9.1 KiB
Python

"""Regression tests: a non-streaming caller must never receive an SSE body.
The buffered Anthropic path copies the upstream response headers wholesale,
``content-type`` included. When the upstream answers a ``stream``-less request
with ``text/event-stream``, that body reached the caller as a ``200`` it could
not parse — the reply was complete, just in the wrong wire format, and the turn
was lost.
The buffered-stream (CCR) path already refused this shape (#2952). These tests
pin the same protection on the plain non-streaming path, plus the recovery that
turns a lost turn into a normal reply.
"""
from __future__ import annotations
import json
import httpx
import pytest
from headroom.proxy.nonstream_sse_policy import (
is_event_stream,
media_type,
should_recover_sse_reply,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_SSE_REPLY = (
"event: message_start\n"
'data: {"type":"message_start","message":{"id":"msg_sse_recovered",'
'"type":"message","role":"assistant","model":"claude-sonnet-4-6",'
'"content":[],"usage":{"input_tokens":11,"output_tokens":0}}}\n'
"\n"
"event: content_block_start\n"
'data: {"type":"content_block_start","index":0,'
'"content_block":{"type":"text","text":""}}\n'
"\n"
"event: content_block_delta\n"
'data: {"type":"content_block_delta","index":0,'
'"delta":{"type":"text_delta","text":"recovered body"}}\n'
"\n"
"event: content_block_stop\n"
'data: {"type":"content_block_stop","index":0}\n'
"\n"
"event: message_delta\n"
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},'
'"usage":{"output_tokens":4}}\n'
"\n"
"event: message_stop\n"
'data: {"type":"message_stop"}\n'
"\n"
)
# Upstream headers as they actually arrive through Anthropic's edge — the
# correlation headers here are what a client uses to report and dedup a turn,
# so the fix must not drop them while correcting the content-type.
_UPSTREAM_SSE_HEADERS = {
"content-type": "text/event-stream; charset=utf-8",
"request-id": "req_011CeC1JTMS8egPL3FBteQay",
"anthropic-ratelimit-requests-remaining": "49",
"cf-ray": "9a1b2c3d4e5f6789-GRU",
"server": "cloudflare",
}
# ---------------------------------------------------------------------------
# Pure policy
# ---------------------------------------------------------------------------
class TestMediaTypeParsing:
@pytest.mark.parametrize(
("header", "expected"),
[
("text/event-stream", "text/event-stream"),
("text/event-stream; charset=utf-8", "text/event-stream"),
("Text/Event-Stream", "text/event-stream"),
(" text/event-stream ", "text/event-stream"),
("application/json", "application/json"),
(None, ""),
("", ""),
],
)
def test_parameters_and_case_are_normalized(self, header, expected) -> None:
assert media_type(header) == expected
def test_is_event_stream_only_matches_sse(self) -> None:
assert is_event_stream("text/event-stream; charset=utf-8") is True
assert is_event_stream("application/json") is False
assert is_event_stream(None) is False
class TestShouldRecoverSseReply:
"""The gate has three deliberate negative arms; each is a separate risk."""
def test_recovers_sse_200_for_a_non_streaming_caller(self) -> None:
assert (
should_recover_sse_reply(
client_requested_stream=False,
status_code=200,
content_type="text/event-stream",
)
is True
)
def test_streaming_caller_is_untouched(self) -> None:
"""A streaming caller asked for SSE — rewriting it would break the turn."""
assert (
should_recover_sse_reply(
client_requested_stream=True,
status_code=200,
content_type="text/event-stream",
)
is False
)
def test_json_reply_is_untouched(self) -> None:
assert (
should_recover_sse_reply(
client_requested_stream=False,
status_code=200,
content_type="application/json",
)
is False
)
@pytest.mark.parametrize("status", [429, 500, 529])
def test_error_status_is_passed_through(self, status) -> None:
"""A non-200 carries an upstream error payload the client should see."""
assert (
should_recover_sse_reply(
client_requested_stream=False,
status_code=status,
content_type="text/event-stream",
)
is False
)
# ---------------------------------------------------------------------------
# Handler end-to-end — the wiring is where the bug lived
# ---------------------------------------------------------------------------
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
def _make_proxy_client() -> TestClient:
config = ProxyConfig(
optimize=True,
mode="token",
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
image_optimize=False,
)
return TestClient(create_app(config))
def _post_non_streaming(client: TestClient):
return client.post(
"/v1/messages",
headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hello"}],
},
)
def _stub_upstream(proxy, response: httpx.Response) -> None:
async def _fake_retry(method, url, headers, body, stream=False, **kwargs): # noqa: ANN001
return response
proxy._retry_request = _fake_retry
class TestNonStreamingCallerNeverGetsAnEventStream:
def test_sse_reply_is_recovered_as_json(self) -> None:
"""Before the fix this returned text/event-stream and the SDK reported
an empty or malformed response despite a complete reply."""
with _make_proxy_client() as client:
_stub_upstream(
client.app.state.proxy,
httpx.Response(
200,
headers=_UPSTREAM_SSE_HEADERS,
content=_SSE_REPLY.encode(),
),
)
response = _post_non_streaming(client)
assert response.status_code == 200
assert "event-stream" not in response.headers["content-type"]
assert response.headers["content-type"].startswith("application/json")
payload = response.json()
assert payload["id"] == "msg_sse_recovered"
assert payload["content"][0]["text"] == "recovered body"
def test_upstream_correlation_headers_survive_recovery(self) -> None:
with _make_proxy_client() as client:
_stub_upstream(
client.app.state.proxy,
httpx.Response(
200,
headers=_UPSTREAM_SSE_HEADERS,
content=_SSE_REPLY.encode(),
),
)
response = _post_non_streaming(client)
assert response.headers["request-id"] == "req_011CeC1JTMS8egPL3FBteQay"
def test_unrecoverable_event_stream_is_refused_not_forwarded(self) -> None:
"""No message_start means no message. Refuse loudly rather than hand
the caller a 200 it cannot parse."""
with _make_proxy_client() as client:
_stub_upstream(
client.app.state.proxy,
httpx.Response(
200,
headers=_UPSTREAM_SSE_HEADERS,
content=b'event: ping\ndata: {"type":"ping"}\n\n',
),
)
response = _post_non_streaming(client)
assert response.status_code == 502
assert "event-stream" not in response.headers["content-type"]
assert response.json()["error"]["type"] == "upstream_protocol_error"
def test_ordinary_json_reply_is_unaffected(self) -> None:
"""Control: the fix must be inert on the overwhelmingly common path."""
with _make_proxy_client() as client:
_stub_upstream(
client.app.state.proxy,
httpx.Response(
200,
json={
"id": "msg_plain",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "ok"}],
"usage": {"input_tokens": 10, "output_tokens": 3},
},
),
)
response = _post_non_streaming(client)
assert response.status_code == 200
assert json.loads(response.content)["id"] == "msg_plain"