fix(proxy): retry upstream 529 overloaded like 429 on both forwarders (#1495)

## Description

Upstream **HTTP 529** (`overloaded_error`) is not retried consistently,
so it leaks to clients even though the sibling 429 path was fixed in
#1221.

- **Streaming forwarder** (`_stream_response`) special-cased only
`status_code == 429`. A `529` falls through to `break` and is forwarded
to the client with **zero retries** — interactive (streaming) Claude
Code sessions see "Overloaded" immediately on a transient Anthropic
overload.
- **Non-streaming forwarder** (`_retry_request`) retried `529` only via
the generic `>= 500` path: it **ignores `Retry-After`** and **raises**
an `HTTPStatusError` on exhaustion instead of returning the clean `529`
verbatim (inconsistent with how 429 is handled right above it).

`529` is documented by Anthropic as the transient "overloaded" status —
semantically identical to 429 for retry purposes ("try again shortly").
This PR routes both through one shared, `Retry-After`-honoring branch.

Related: #1221 (added the 429 retry this extends).

## Type of Change

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

## Changes Made

- Add `RETRYABLE_OVERLOAD_STATUSES = frozenset({429, 529})` to
`proxy/helpers.py` as the single source of truth shared by both
forwarders.
- `streaming.py`: retry when `status_code in
RETRYABLE_OVERLOAD_STATUSES` (was `== 429`); log line now interpolates
the actual status.
- `server.py` `_retry_request`: handle `429`/`529` in one
`Retry-After`-honoring branch that returns the status verbatim once
`retry_max_attempts` is exhausted (529 no longer goes through the 5xx
raise path). Other 4xx/5xx behavior is unchanged.
- No new dependencies; no config/API surface changes. Retry volume stays
bounded by the existing `retry_max_attempts` / `retry_*_delay_ms`
config.

## 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 Output

Reproduced the CI `lint` + `commitlint` jobs exactly (pinned
`ruff==0.15.17`, `mypy==1.20.2`, `@commitlint/config-conventional`),
plus the affected proxy test subset:

```text
# New tests in tests/test_proxy_retry_429.py — 3 of 4 fail on main, all pass here
# BEFORE (source reverted, new tests kept):
FAILED ::test_retry_request_returns_529_verbatim_on_exhaustion  - httpx.HTTPStatusError: Server error: 529 (raised, not returned verbatim)
FAILED ::test_retry_request_honors_retry_after_on_529           - slept ~0.001s (jitter), ignored Retry-After: 2
FAILED ::test_stream_response_retries_529                       - assert 1 == 2 (streaming 529 forwarded raw, no retry)
3 failed, 7 passed
# AFTER (this branch):
10 passed in 2.53s

# Adjacent proxy suites (regression check) — retry + streaming resilience + ratelimit headers + handler helpers + request logger:
79 passed in 6.61s

$ ruff check .            -> All checks passed!
$ ruff format --check .   -> 1005 files already formatted
$ mypy headroom --ignore-missing-imports
  Success: no issues found in 400 source files
$ commitlint --from <base> --to HEAD
  ✔ found 0 problems, 0 warnings
```

## Real Behavior Proof

- Environment: Linux, Python 3.14.0; the proxy running **from this
branch** (`headroom proxy --mode token --backend anthropic --no-optimize
...`) in front of a fake Anthropic upstream that returns a real HTTP 529
(`{"error":{"type":"overloaded_error"}}`, `Retry-After: 0`) on request
#1 then a 200 SSE stream on request #2. Real proxy process over real
sockets (a synthetic upstream is used because real Anthropic 529s cannot
be induced on demand).
- Exact command / steps: started the fake upstream on `:9911` and the
branch proxy on `:9912` with `--anthropic-api-url
http://127.0.0.1:9911`, then sent a streaming request: `curl -sN -X POST
http://127.0.0.1:9912/v1/messages -H 'x-api-key: …' -H
'anthropic-version: 2023-06-01' -H 'content-type: application/json' -d
'{"model":"claude-3-5-sonnet-20241022","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"hi"}]}'`
(full scripts in the code block below).
- Observed result: the client received `HTTP/1.1 200 OK` and the
complete SSE stream (`message_start … "hello" … message_stop`), and the
fake upstream logged **two** calls — `call #1` returned 529, `call #2`
returned 200 — i.e. the proxy transparently retried the 529 and the
overload never reached the client. On `main` the streaming path forwards
the 529 on call #1 with no retry, exactly what
`test_stream_response_retries_529` pins at `calls == 1`.
- Not tested: a real (non-synthetic) Anthropic 529 (cannot induce on
demand); the full sharded `pytest tests scripts/tests` job (needs CI
model/torch infra) — ran the proxy suite subset above instead; the Rust
jobs and non-Anthropic backends (unchanged by this PR).

```bash
# fake_upstream.py: 529 (Retry-After: 0) on call #1, then 200 SSE; logs each call
python fake_upstream.py &                              # :9911
headroom proxy --host 127.0.0.1 --port 9912 \
    --anthropic-api-url http://127.0.0.1:9911 \
    --mode token --backend anthropic \
    --no-optimize --no-cache --no-rate-limit &         # :9912 (this branch)
curl -sN -D - -X POST http://127.0.0.1:9912/v1/messages \
    -H 'x-api-key: sk-ant-test' -H 'anthropic-version: 2023-06-01' \
    -H 'content-type: application/json' \
    -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":16,"stream":true,
         "messages":[{"role":"user","content":"hi"}]}'
# -> HTTP/1.1 200 OK + full SSE;  upstream log: "call #1" (529) then "call #2" (200)
```

## 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
- [ ] I have made corresponding changes to the documentation (N/A — no
doc/config surface change)
- [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 have updated the CHANGELOG.md if applicable

## Additional Notes

- Replicated the CI `lint` job exactly (fresh venv, pinned
`ruff==0.15.17` + `mypy==1.20.2`, `ruff check .` / `ruff format --check
.` / `mypy headroom --ignore-missing-imports`) and `commitlint`
(`@commitlint/config-conventional`) — all clean. The full `test` shards
(model/torch) and Rust jobs were not run locally (no GPU/model cache /
Rust toolchain in this environment); they are unaffected by this
Python-only change.
- `CHANGELOG.md`'s `## Unreleased` section currently contains unresolved
merge-conflict markers on `main` (`<<<<<<< … >>>>>>>`) unrelated to this
PR; I added my entry to the clean `### Bug Fixes` list above that region
without touching the conflicts.
This commit is contained in:
Rick van Hattem 2026-06-28 22:21:02 +02:00 committed by GitHub
parent 17c7347402
commit 547b15dab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 104 additions and 23 deletions

View file

@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **proxy:** stop discarding a finished compression on very large requests. After the transform pipeline completed, a telemetry-only waste-signal re-parse of the *original* messages ran on the critical path; on huge Claude Code transcripts (~400k tokens) that parse could exceed the Anthropic compression timeout, so the proxy failed open and forwarded the uncompressed request despite "Pipeline complete" logging real savings (`tokens_saved: 0`, `transforms_applied: []`, ~31s latency). Waste-signal detection is now skipped above `MAX_WASTE_SIGNAL_DETECTION_TOKENS` (100k) so the compression result stays on the critical path ([#296](https://github.com/chopratejas/headroom/issues/296)).
* **codex:** retag existing Codex threads when `headroom init` injects the `headroom` provider, so Codex Desktop history stays visible. Codex filters its sidebar/search by the active `model_provider`; the init path set `model_provider = "headroom"` without retagging, so existing native `openai` threads disappeared from the menu (data was never deleted, only hidden). `_ensure_codex_provider` now reconciles thread tags openai→headroom, matching what the install and `wrap` paths already do; `headroom unwrap codex` handles the revert direction ([#961](https://github.com/chopratejas/headroom/issues/961)).
* **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)).
* **proxy:** retry upstream `529 overloaded_error` like a 429 on both the streaming and non-streaming forwarders, honoring `Retry-After`. The streaming path previously surfaced a 529 straight to the client with no retry (interactive sessions saw "Overloaded" immediately), and `_retry_request` retried it only via the generic 5xx path — raising on exhaustion instead of returning the 529 verbatim, and ignoring `Retry-After`. A shared `RETRYABLE_OVERLOAD_STATUSES = {429, 529}` keeps the two forwarders in agreement (extends [#1221](https://github.com/headroomlabs-ai/headroom/issues/1221)).
* **gemini:** run compression off the asyncio event loop. The Gemini handlers (`generateContent`, Cloud Code stream, `countTokens`) ran the CPU-bound compression pipeline (Magika detection plus ML compression) synchronously on the loop, stalling every concurrent request for the duration of each Gemini request's compression. They now offload it via the shared compression executor, matching the existing OpenAI and Anthropic paths.
* **proxy:** queue mid-turn user messages on non-Bedrock streaming path instead of silently dropping them — closes [#902](https://github.com/headroomlabs-ai/headroom/issues/902).
* **proxy:** add `--protect-tool-results` / `HEADROOM_PROTECT_TOOL_RESULTS` to prevent lossy compression of exact-output tool results (e.g. `Bash cat`/`grep` results) — closes [#1307](https://github.com/headroomlabs-ai/headroom/issues/1307).

View file

@ -13,7 +13,11 @@ import time
from typing import TYPE_CHECKING, Any
from headroom.proxy.auth_mode import classify_client
from headroom.proxy.helpers import jitter_delay_ms, retry_after_ms
from headroom.proxy.helpers import (
RETRYABLE_OVERLOAD_STATUSES,
jitter_delay_ms,
retry_after_ms,
)
if TYPE_CHECKING:
from fastapi.responses import Response, StreamingResponse
@ -997,11 +1001,12 @@ class StreamingMixin:
headers=dict(upstream_response.headers),
status_code=upstream_response.status_code,
)
# Retry upstream 429s honoring Retry-After — the streaming
# sibling of the _retry_request path (#1221); on exhaustion,
# fall through to forward the 429 to the client.
# Retry transient overloads (429 rate-limit, 529 overloaded)
# honoring Retry-After — the streaming sibling of the
# _retry_request path (#1221); on exhaustion, fall through to
# forward the status to the client.
if (
upstream_response.status_code == 429
upstream_response.status_code in RETRYABLE_OVERLOAD_STATUSES
and self.config.retry_enabled
and attempt < retry_attempts - 1
):
@ -1014,7 +1019,7 @@ class StreamingMixin:
)
await upstream_response.aclose()
logger.warning(
f"[{request_id}] Upstream 429 "
f"[{request_id}] Upstream {upstream_response.status_code} "
f"(attempt {attempt + 1}/{retry_attempts}), "
f"retrying in {delay_with_jitter:.0f}ms"
)

View file

@ -952,6 +952,14 @@ def retry_after_ms(response: httpx.Response, max_ms: int) -> float | None:
return min(max(seconds, 0.0) * 1000.0, float(max_ms))
# Transient upstream statuses worth retrying with backoff: 429 (rate limit) and
# 529 (Anthropic ``overloaded_error``). Both mean "the server is temporarily
# limiting/overloaded — try again shortly", unlike other 4xx which signal a
# problem with the request itself. Single source of truth so the streaming and
# non-streaming forwarders agree on what is retriable.
RETRYABLE_OVERLOAD_STATUSES: frozenset[int] = frozenset({429, 529})
# Image compression availability (do not retain a global compressor instance)
_image_compressor_available: bool | None = None

View file

@ -128,6 +128,7 @@ from headroom.proxy.helpers import (
MAX_MESSAGE_ARRAY_LENGTH, # noqa: F401
MAX_REQUEST_BODY_SIZE, # noqa: F401
MAX_SSE_BUFFER_SIZE, # noqa: F401
RETRYABLE_OVERLOAD_STATUSES,
_get_context_tool_stats,
_get_image_compressor, # noqa: F401
_get_rtk_stats, # noqa: F401
@ -1759,22 +1760,11 @@ class HeadroomProxy(
url, **post_kwargs
)
# Don't retry client errors (4xx) — except 429, the most
# retriable status, which carries an authoritative Retry-After (#1221).
if 400 <= response.status_code < 500 and response.status_code != 429:
return response
# Retry server errors (5xx)
if response.status_code >= 500:
raise httpx.HTTPStatusError(
f"Server error: {response.status_code}",
request=response.request,
response=response,
)
# Rate limit (429): retry honoring Retry-After, but return it
# verbatim once exhausted — a clean rate-limit signal beats a 5xx.
if response.status_code == 429:
# Transient overloads (429 rate-limit, 529 overloaded):
# retry honoring Retry-After, but return verbatim once
# exhausted — a clean overload signal beats a synthesized 5xx
# (extends #1221 to 529, Anthropic's overloaded_error).
if response.status_code in RETRYABLE_OVERLOAD_STATUSES:
if (
not self.config.retry_enabled
or attempt >= self.config.retry_max_attempts - 1
@ -1788,11 +1778,24 @@ class HeadroomProxy(
attempt,
)
logger.warning(
f"Upstream 429 (attempt {attempt + 1}), retrying in {delay_ms:.0f}ms"
f"Upstream {response.status_code} (attempt {attempt + 1}), "
f"retrying in {delay_ms:.0f}ms"
)
await asyncio.sleep(delay_ms / 1000)
continue
# Don't retry other client errors (4xx)
if 400 <= response.status_code < 500:
return response
# Retry other server errors (5xx)
if response.status_code >= 500:
raise httpx.HTTPStatusError(
f"Server error: {response.status_code}",
request=response.request,
response=response,
)
return response
except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:

View file

@ -155,3 +155,67 @@ def test_stream_response_retries_429() -> None:
)
)
assert transport.calls == 2 # streaming 429 retried, not forwarded raw
# --- 529 overloaded: same transient-retry path as 429 --------------------
#
# 529 is Anthropic's ``overloaded_error``. Like 429 it means "try again
# shortly", so both forwarders must retry it honoring Retry-After. Before this
# fix the streaming path forwarded a 529 to the client raw (zero retries), and
# _retry_request retried it only via the generic 5xx path — raising on
# exhaustion instead of returning the 529 verbatim, and ignoring Retry-After.
def test_retry_request_retries_529_then_succeeds() -> None:
transport = _RateLimitTransport(fail_status=529, fail_times=1, retry_after="0")
proxy = _proxy_with(transport)
resp = asyncio.run(proxy._retry_request("POST", "https://up/v1/messages", {}, {"messages": []}))
assert resp.status_code == 200
assert transport.calls == 2 # one 529 + one success — the retry happened
def test_retry_request_returns_529_verbatim_on_exhaustion() -> None:
# Always 529: must return the 529 to the client, NOT raise / convert to 5xx.
transport = _RateLimitTransport(fail_status=529, fail_times=99, retry_after="0")
proxy = _proxy_with(transport, max_attempts=3)
resp = asyncio.run(proxy._retry_request("POST", "https://up/v1/messages", {}, {"messages": []}))
assert resp.status_code == 529
assert transport.calls == 3 # exhausted all attempts, returned verbatim
def test_retry_request_honors_retry_after_on_529(monkeypatch) -> None:
slept: list[float] = []
async def _fake_sleep(seconds: float) -> None:
slept.append(seconds)
monkeypatch.setattr("headroom.proxy.server.asyncio.sleep", _fake_sleep)
transport = _RateLimitTransport(fail_status=529, fail_times=1, retry_after="2")
proxy = _proxy_with(transport)
asyncio.run(proxy._retry_request("POST", "https://up/v1/messages", {}, {"messages": []}))
# Retry-After: 2s honored for 529 just like 429.
assert slept and abs(slept[0] - 2.0) < 0.01
def test_stream_response_retries_529() -> None:
# The gap this PR closes: an interactive (streaming) session hitting a 529
# used to get "Overloaded" surfaced immediately, with no retry.
transport = _RateLimitTransport(fail_status=529, fail_times=1, retry_after="0", sse=True)
proxy = _proxy_with(transport)
asyncio.run(
proxy._stream_response(
"https://up/v1/messages",
{},
{"messages": []},
"anthropic",
"claude-3",
"r1",
0,
0,
0,
[],
{},
0.0,
)
)
assert transport.calls == 2 # streaming 529 retried, not forwarded raw