fix(proxy): return 502, not 200, when upstream connect retries are exhausted (#3083)

## Description

When every connect retry to the upstream API fails,
`_stream_response_inner` synthesizes its own SSE error response (added
in #1639, so an h2 `StreamReset` wouldn't surface as an unhandled 502).
It was built without a `status_code`, so Starlette defaulted it to
**200**.

A 200 carrying a lone `event: error` frame and no `message_start` is
indistinguishable, to every Anthropic/OpenAI SDK, from a successful
stream that produced no events. Claude Code reports:

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

The client also cannot recover, because 200 is not a retryable status.

**It does not self-heal.** Compression fails open on timeout, so the
proxy forwards the full uncompressed body; the client retries, re-sends
the same oversized payload, hits the same transport failure, and gets
another 200. The session is stuck until the client is pointed away from
the proxy.

Related — same *symptom*, different root cause, so this closes none of
them: #3040, #3055, #3019, #2952 (CCR buffered-stream conversion),
#3071, #3017. Worth noting that #3040 ("first messages succeed, fails
after several turns", closed `NOT_PLANNED`) matches this failure's shape
exactly.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

Marked breaking because the status code on this path changes 200 to 502.
See **Runtime Rollout Safety**.

## Changes Made

- `handlers/streaming.py` — the synthesized transport-error response now
returns **502**. The structured SSE body is unchanged for clients that
read it. No body byte has been forwarded at that point, so the status
line is still ours to set.
- `prometheus_metrics.py` — new
`headroom_upstream_connection_errors_total{provider}`. This path
forwards no upstream status, so there was nothing to attribute the
failure to in `/metrics`; it survived only as a log line. Mirrors
`record_compression_failed` and takes the same `_obs_counter_lock`.
- `server.py` — `HEADROOM_LOG_LEVEL` for uvicorn's level, previously
hardcoded to `"warning"` with no env var and no CLI flag. Default
unchanged. An unrecognized value warns and falls back rather than
raising (uvicorn raises `KeyError` on unknown levels).
- `docs/content/docs/proxy.mdx` — documents the new env var in the
Observability table.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

`test_stream_reset_exhaustion_yields_sse_error_not_crash` asserted the
SSE body but never the status — which is how the 200 survived. Added a
test that pins the status specifically, a happy-path guard, and coverage
for the counter and the env-var resolver.

### Test Output

```text
$ python -m pytest tests/test_h2_stream_reset_retry.py tests/test_prometheus_obs_counters.py tests/test_uvicorn_log_level_env.py -q
29 passed in 5.26s

$ python -m ruff check .
All checks passed!

$ python -m ruff format --check .
1506 files already formatted

$ python -m mypy headroom/proxy/handlers/streaming.py headroom/proxy/prometheus_metrics.py headroom/proxy/server.py
Success: no issues found in 3 source files

# Fails before the fix (status_code=502 line removed, nothing else changed):
$ python -m pytest tests/test_h2_stream_reset_retry.py -k status_is_not_200
    assert result.status_code == 502
E   assert 200 == 502
FAILED tests/test_h2_stream_reset_retry.py::test_stream_reset_exhaustion_status_is_not_200
1 failed, 5 deselected in 1.28s
```

Broader regression run (181 passed): `test_h2_stream_reset_retry`,
`test_prometheus_obs_counters`, `test_uvicorn_log_level_env`,
`test_prometheus_label_escaping`, `test_observability_metrics`,
`test_prometheus_stage_timing_concurrency`,
`test_proxy_streaming_ratelimit_headers`, `test_proxy_retry_429`,
`test_proxy_byte_faithful_forwarding`, `test_ws_http_fallback`,
`test_mid_turn_steering`, `test_proxy_anthropic_cache_stability`.

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.15, headroom @ this branch.
Genuine `create_app()` FastAPI app under real uvicorn — no mocks, no
TestClient. Upstream pinned to `http://127.0.0.1:59999` (a closed port),
so every connect attempt is a real TCP refusal, producing a real
`httpx.ConnectError` (an `httpx.TransportError`) into the branch under
test. `retry_max_attempts=2`.
- Exact command / steps: boot the real app with
`HEADROOM_LOG_LEVEL=info` and
`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")`, POST a
`stream:true` request to `/v1/messages`, then scrape `/metrics`.
Verbatim commands below.
- Observed result: `HTTP_STATUS=502` (previously 200), structured SSE
error body intact,
`headroom_upstream_connection_errors_total{provider="anthropic"} 1`, and
a uvicorn access line present only because `HEADROOM_LOG_LEVEL=info` was
honored. Verbatim output below.
- Not tested: the h2 `StreamReset` variant specifically — reproduced via
`ConnectError`, a sibling `httpx.TransportError` travelling the
identical code path (the existing `test_stream_reset_exhaustion_*` tests
cover `RemoteProtocolError` at unit level). Not exercised against the
OpenAI, Gemini, or Bedrock streaming handlers, which have their own
error paths. No load or concurrency testing.

Commands run after the patch:

```bash
# boot the real app with a dead upstream and the new env var set
HEADROOM_LOG_LEVEL=info python run_proxy_proof.py   # ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")

curl -s -o resp.txt -w "HTTP_STATUS=%{http_code}\ncontent_type=%{content_type}\n" \
  http://127.0.0.1:8799/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: proof-key" \
  -H "anthropic-version: 2023-06-01" \
  -d @request.json    # {"model":"claude-opus-5","max_tokens":64,"stream":true,"messages":[...]}
```

After-fix evidence:

```text
PROOF: HEADROOM_LOG_LEVEL='info' -> uvicorn log_level='info'
PROOF: upstream pinned to http://127.0.0.1:59999 (closed port)

HTTP_STATUS=502
content_type=text/event-stream; charset=utf-8

event: error
data: {"type": "error", "error": {"type": "connection_error", "message": "Failed to connect to upstream API: All connection attempts failed"}}
```

```text
$ curl -s http://127.0.0.1:8799/metrics | grep upstream_connection_errors
# HELP headroom_upstream_connection_errors_total Exhausted-retries upstream transport failures by provider; the proxy answered 502 itself because no upstream response arrived
# TYPE headroom_upstream_connection_errors_total counter
headroom_upstream_connection_errors_total{provider="anthropic"} 1
```

```text
# uvicorn access log — present only because HEADROOM_LOG_LEVEL=info was honored:
INFO:     127.0.0.1:62472 - "POST /v1/messages HTTP/1.1" 502 Bad Gateway
INFO:     127.0.0.1:62479 - "GET /metrics HTTP/1.1" 200 OK
```

All three changes are exercised end to end: the status is 502, the
structured body survives, the counter increments, and the env var takes
effect.

Separately, this ran against a real deployment: the fix is live on a
self-hosted proxy at `0.35.1-alpha.3` (Azure Container Apps, Cloudflare
in front), where the original HTTP 200 was first observed against
`0.35.1-alpha.1`.

## Runtime Rollout Safety

- Rollout-managed feature(s): none — unconditional bug fix, no flag.
- Minimum rollout channel: n/a — ships with the change.
- Stable/default behavior changed: yes. This path returns 502 instead of
200. `HEADROOM_LOG_LEVEL` and the new counter both default to current
behavior (`warning`; the counter is absent from `/metrics` until the
first occurrence).
- Kill switch / disable path: none. Happy to add an env guard if you
would prefer it staged, though a 200 on this path is never correct.
- Unsafe override required: no.
- Qualification impact: any client treating the synthesized 200 as
success now sees a 5xx. That is the fix — such a client was silently
accepting a truncated response. Retry-on-5xx logic in the Anthropic and
OpenAI SDKs will now retry a transient transport failure, which is the
intended behavior.
- Rollback path: revert the commit; single and self-contained.

## Review Readiness

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

## Checklist

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

## Screenshots (if applicable)

N/A — terminal output above.

## Additional Notes

**Scope.** Three changes in one PR, against the "one logical change"
guidance. They share a single root cause: this bug was only findable by
reading `/metrics`, because the failing path emitted no status, no
counter, and (see below) no usable log line. The counter and the env var
are the observability that should have made it a five-minute diagnosis
instead of a forensic exercise. Happy to split the `HEADROOM_LOG_LEVEL`
change into its own PR if you would rather keep the fix minimal — just
say so.

**Related defect, filed separately as #3087.** While producing the proof
above I found that the proxy's own `logger.error("Connection error to
upstream API: ...")` never reaches stdout: that run produced **zero**
`headroom.proxy` logger lines, only uvicorn's own. Root cause is
`_setup_file_logging()` setting `propagate = False` on the `headroom`
logger (`helpers.py:1536`), which sends every application record to
`~/.headroom/logs/proxy.log` and nowhere else — invisible in any
container, where stdout is the log channel. That is precisely why this
PR adds a counter rather than trusting a log line. Not fixed here: the
right remedy is a maintainer call, so it is written up in #3087 with a
repro rather than folded into this PR.

**No dependency changes.**

The dead-upstream harness used for the proof above is ~25 lines
(`ProxyConfig(anthropic_api_url="http://127.0.0.1:59999")` +
`uvicorn.run(create_app(config))`); happy to contribute it as an e2e
test if that is useful.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serge ARADJ 2026-08-20 15:32:30 +02:00 committed by GitHub
parent b88b9078d8
commit a3d9424de9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 197 additions and 4 deletions

View file

@ -284,6 +284,7 @@ Rewrite the upstream model per request — for example, send small, tool-free ca
| `--telemetry` / `HEADROOM_TELEMETRY` | off | **Local-only** usage stats for your own `/stats`, `/metrics`, and dashboard. Nothing leaves the machine. |
| `--log-file` / `HEADROOM_LOG_FILE` | none | JSONL request/response log. |
| `--log-messages` | `false` | Include full message bodies in the log (may contain sensitive data). |
| `HEADROOM_LOG_LEVEL` | `warning` | uvicorn's log level (`critical`, `error`, `warning`, `info`, `debug`, `trace`). Raise to `info` for the per-request access log when diagnosing a deployed proxy. An unrecognized value warns and falls back to `warning`. |
| `HEADROOM_OTEL_METRICS_ENABLED` | `false` | Export OpenTelemetry metrics (`HEADROOM_OTEL_METRICS_ENDPOINT`, …). See [OTLP export](/docs/metrics#opentelemetry-otlp-export). |
| `HEADROOM_LANGFUSE_ENABLED` | `false` | Emit Langfuse traces (`LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`). |

View file

@ -1340,11 +1340,22 @@ class StreamingMixin:
if upstream_response is None:
raise last_connect_error or RuntimeError("upstream connection did not start")
# Retries exhausted (or a transport failure escaped the loop): emit a
# clean SSE error instead of letting an h2 StreamReset bubble up as a
# 502. Covers ConnectError/timeouts and Local/RemoteProtocolError. (#1639)
# clean SSE error instead of letting an h2 StreamReset bubble up as an
# unhandled 502. Covers ConnectError/timeouts and Local/RemoteProtocol-
# Error. (#1639)
#
# The status MUST stay non-2xx. No body byte has been forwarded yet, so
# the status line is still ours to set, and a 200 here is
# indistinguishable — to every Anthropic/OpenAI SDK — from a successful
# stream that produced no events: the client reports "empty or malformed
# response (HTTP 200)" and cannot retry, because 200 is not retryable.
# 502 keeps the structured SSE body for clients that read it while
# letting SDK retry logic treat a transient upstream transport failure
# as what it is.
except httpx.TransportError as e:
error_msg = str(e) or repr(e)
logger.error(f"[{request_id}] Connection error to upstream API: {error_msg}")
self.metrics.record_upstream_connection_error(provider)
async def _error_gen():
error_event = {
@ -1357,7 +1368,11 @@ class StreamingMixin:
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
self._cleanup_mid_turn_stream(session_key)
return StreamingResponse(_error_gen(), media_type="text/event-stream")
return StreamingResponse(
_error_gen(),
status_code=502,
media_type="text/event-stream",
)
# Capture Codex rate-limit window data from the upstream response
# headers, for *every* status. Codex (gpt-5.x) almost always streams, so

View file

@ -152,6 +152,14 @@ class PrometheusMetrics:
# whether the compression budget is too tight vs. a real bug.
self.compression_failed_by_reason: dict[str, int] = defaultdict(int)
# Upstream transport failures on the streaming path, keyed by provider.
# Raised when every connect retry is exhausted and the proxy synthesizes
# its own error response instead of forwarding an upstream status. That
# path emits no upstream status code to attribute the failure to, so
# without this counter it is invisible in metrics and survives only as a
# log line.
self.upstream_connection_errors_by_provider: dict[str, int] = defaultdict(int)
# Kompress size-gate outcomes, keyed by outcome ("within",
# "exceeded"). The gate routes oversized blocks away from ML
# compression (ContentRouter._kompress_max_tokens, #1171). This
@ -355,6 +363,7 @@ class PrometheusMetrics:
self.savings_by_source.clear()
with self._obs_counter_lock:
self.compression_failed_by_reason.clear()
self.upstream_connection_errors_by_provider.clear()
self.kompress_size_gate_by_outcome.clear()
self.compression_quarantine_by_event.clear()
@ -568,6 +577,18 @@ class PrometheusMetrics:
with self._obs_counter_lock:
self.compression_failed_by_reason[reason or "error"] += 1
def record_upstream_connection_error(self, provider: str) -> None:
"""Record one exhausted-retries upstream transport failure.
Called from the streaming handler's ``httpx.TransportError`` fallback
(handlers/streaming.py), where the proxy synthesizes its own 502
because no upstream response ever arrived. Guarded by
``_obs_counter_lock`` for the same reason as
``record_compression_failed``.
"""
with self._obs_counter_lock:
self.upstream_connection_errors_by_provider[provider or "unknown"] += 1
def record_kompress_size_gate(self, outcome: str) -> None:
"""Record one kompress size-gate decision, bucketed by ``outcome``.
@ -1404,9 +1425,23 @@ class PrometheusMetrics:
# then format outside it (see _obs_counter_lock).
with self._obs_counter_lock:
compression_failed = dict(self.compression_failed_by_reason)
upstream_conn_errors = dict(self.upstream_connection_errors_by_provider)
kompress_size_gate = dict(self.kompress_size_gate_by_outcome)
compression_quarantine = dict(self.compression_quarantine_by_event)
if upstream_conn_errors:
lines.extend(
[
"# HELP headroom_upstream_connection_errors_total Exhausted-retries upstream transport failures by provider; the proxy answered 502 itself because no upstream response arrived",
"# TYPE headroom_upstream_connection_errors_total counter",
]
)
for prov, count in upstream_conn_errors.items():
lines.append(
f'headroom_upstream_connection_errors_total{{provider="{_escape_label_value(prov)}"}} {count}'
)
lines.append("")
if compression_failed:
lines.extend(
[

View file

@ -5485,11 +5485,18 @@ def run_server(
else:
app_target = create_app(config)
# "warning" keeps the default terminal quiet (uvicorn's access log is one line
# per request). It was previously hardcoded, which left deployed proxies with
# no way to turn request logging on: operators diagnosing a production
# incident could not see uvicorn's view of the traffic at all, with no env var
# and no CLI flag to change it. Overridable now; the default is unchanged.
uvicorn_log_level = _resolve_uvicorn_log_level()
uvicorn.run(
app_target,
host=config.host,
port=config.port,
log_level="warning",
log_level=uvicorn_log_level,
workers=workers if workers > 1 else None, # None = single process (default)
limit_concurrency=limit_concurrency,
# Defense-in-depth: the loopback guard for /debug/* endpoints trusts
@ -5556,6 +5563,30 @@ def _get_env_str(name: str, default: str) -> str:
return os.environ.get(name, default)
# uvicorn rejects anything outside this set with a KeyError during startup, so an
# operator typo in HEADROOM_LOG_LEVEL must not be able to stop the proxy booting.
_UVICORN_LOG_LEVELS = frozenset({"critical", "error", "warning", "info", "debug", "trace"})
_UVICORN_LOG_LEVEL_DEFAULT = "warning"
def _resolve_uvicorn_log_level() -> str:
"""Resolve uvicorn's log level from ``HEADROOM_LOG_LEVEL``.
Falls back to the previous hardcoded default on an unset or unrecognized
value, warning loudly rather than failing the boot.
"""
raw = _get_env_str("HEADROOM_LOG_LEVEL", _UVICORN_LOG_LEVEL_DEFAULT).strip().lower()
if raw in _UVICORN_LOG_LEVELS:
return raw
logger.warning(
"Ignoring unrecognized HEADROOM_LOG_LEVEL=%r; using %r. Valid values: %s",
raw,
_UVICORN_LOG_LEVEL_DEFAULT,
", ".join(sorted(_UVICORN_LOG_LEVELS)),
)
return _UVICORN_LOG_LEVEL_DEFAULT
def _parse_exclude_tools(cli_excludes: str | None) -> set[str]:
"""Parse extra never-compress tool names from CLI args and env var.

View file

@ -30,6 +30,7 @@ def _mock_proxy():
proxy._config.retry_max_delay_ms = 0
proxy.config = proxy._config
proxy.memory_handler = None
proxy.metrics = MagicMock()
proxy._parse_sse_usage_from_buffer = MagicMock(return_value=None)
proxy._finalize_stream_response = AsyncMock(return_value=None)
return proxy
@ -142,3 +143,37 @@ async def test_stream_reset_exhaustion_yields_sse_error_not_crash():
assert proxy.http_client.send.await_count == 2
assert b"event: error" in body
assert b"connection_error" in body
@pytest.mark.asyncio
async def test_stream_reset_exhaustion_status_is_not_200():
"""The synthesized error response must not claim success.
A 200 here is indistinguishable, to every Anthropic/OpenAI SDK, from a
successful stream that produced no events the client reports "empty or
malformed response (HTTP 200)" and cannot retry, because 200 is not a
retryable status. This asserts the status line specifically: the body
assertions above passed for the entire time the status was 200.
"""
proxy = _mock_proxy()
proxy.http_client.build_request = MagicMock(return_value=MagicMock())
proxy.http_client.send = AsyncMock(side_effect=httpx.RemoteProtocolError("reset"))
result = await _run_stream(proxy)
assert result.status_code == 502
proxy.metrics.record_upstream_connection_error.assert_called_once()
@pytest.mark.asyncio
async def test_successful_stream_still_returns_200():
"""Guard the happy path against the 502 change above."""
proxy = _mock_proxy()
good = _good_stream_response([b'event: message_stop\ndata: {"type":"message_stop"}\n\n'])
proxy.http_client.build_request = MagicMock(return_value=MagicMock())
proxy.http_client.send = AsyncMock(return_value=good)
result = await _run_stream(proxy)
assert result.status_code == 200
proxy.metrics.record_upstream_connection_error.assert_not_called()

View file

@ -8,6 +8,8 @@ Covers the related counters added to ``PrometheusMetrics``:
via the observer hook, split into "exceeded" vs "within".
* ``headroom_compression_quarantine_total{event}`` records quarantine
activation and immediate executor skips while a timed-out worker remains.
* ``headroom_upstream_connection_errors_total{provider}`` recorded when the
streaming path exhausts its connect retries and answers 502 itself.
Imports only the metrics module so the test stays free of heavy ML deps.
"""
@ -41,6 +43,35 @@ def test_record_compression_failed_empty_reason_defaults_to_error() -> None:
assert metrics.compression_failed_by_reason["error"] == 1
def test_record_upstream_connection_error_buckets_by_provider() -> None:
metrics = PrometheusMetrics()
metrics.record_upstream_connection_error("anthropic")
metrics.record_upstream_connection_error("openai")
metrics.record_upstream_connection_error("openai")
assert metrics.upstream_connection_errors_by_provider["anthropic"] == 1
assert metrics.upstream_connection_errors_by_provider["openai"] == 2
def test_record_upstream_connection_error_empty_provider_defaults_to_unknown() -> None:
metrics = PrometheusMetrics()
metrics.record_upstream_connection_error("")
assert metrics.upstream_connection_errors_by_provider["unknown"] == 1
async def test_upstream_connection_errors_exported_in_prometheus_text() -> None:
metrics = PrometheusMetrics()
metrics.record_upstream_connection_error("anthropic")
text = await metrics.export()
assert "# TYPE headroom_upstream_connection_errors_total counter" in text
assert 'headroom_upstream_connection_errors_total{provider="anthropic"} 1' in text
def test_record_kompress_size_gate_buckets_by_outcome() -> None:
metrics = PrometheusMetrics()

View file

@ -0,0 +1,45 @@
"""HEADROOM_LOG_LEVEL resolution for uvicorn's log level.
uvicorn's level was previously hardcoded to "warning", so a deployed proxy had
no way to turn request logging on no env var, no CLI flag. These tests pin the
override and, more importantly, pin that an operator typo cannot stop the proxy
booting (uvicorn raises on an unknown level).
"""
from __future__ import annotations
import pytest
from headroom.proxy.server import _resolve_uvicorn_log_level
@pytest.fixture(autouse=True)
def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("HEADROOM_LOG_LEVEL", raising=False)
def test_defaults_to_warning_when_unset() -> None:
assert _resolve_uvicorn_log_level() == "warning"
@pytest.mark.parametrize("level", ["critical", "error", "warning", "info", "debug", "trace"])
def test_accepts_every_uvicorn_level(level: str, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_LOG_LEVEL", level)
assert _resolve_uvicorn_log_level() == level
def test_normalizes_case_and_whitespace(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HEADROOM_LOG_LEVEL", " INFO \n")
assert _resolve_uvicorn_log_level() == "info"
@pytest.mark.parametrize("bad", ["verbose", "WARN", "", "10"])
def test_unrecognized_value_falls_back_instead_of_raising(
bad: str, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A typo must degrade to the old default, never fail the boot."""
monkeypatch.setenv("HEADROOM_LOG_LEVEL", bad)
assert _resolve_uvicorn_log_level() == "warning"