fix(api): retry 429/5xx with backoff, and stop replaying streamed responses - #20
Merged
Conversation
…ponses
There was no retry anywhere on the request path. A single 429 failed the
whole turn — the one failure mode guaranteed to happen under load. 529 was
handled (fallback model) but 429 was not, and `retry-after` was ignored.
New `api/retry.rs`:
- retries 408/429/500/502/503/504 and connect/timeout transport errors
- honours `retry-after` (numeric seconds, plus the `retry-after-ms`
extension) verbatim — no jitter, no growth; the server named the time
- exponential backoff with equal jitter otherwise
- bounded three ways: 5 attempts, 150s total, and a 120s cap on
`retry-after` above which it fails fast rather than freezing the UI
- reports every backoff to the user; a silent 30s sleep reads as a hang
Wired into all three send sites (Anthropic streaming + non-streaming, and
OpenAI-compat, where Groq/OpenRouter throttle hardest). 529 is deliberately
still excluded: it is owned by the model-switch fast path, which beats
sleeping. `send_with_retry` returns the final response even on an error
status, so the OpenAI-compat "does not support tools" 400 sniff and the 529
detection keep working unchanged.
Also fixes a latent bug found in the TUI's own retry loop. It re-issued
`messages_stream` on any error matching `reset by peer`/`connection`, but
`AppEvent::TextChunk` appends to `app.streaming` and is never rewound — so a
*mid-stream* failure replayed the entire response and the user saw a
truncated answer followed by a complete one, with the doubled text persisted
to the session. Now guarded by `may_retry_stream`, which refuses to retry
once any output has been streamed. 429/5xx were also removed from that loop
since the client now owns them; keeping both meant 3 x 5 = 15 attempts that
ignored `retry-after`.
The base URL override used by the tests is `#[cfg(test)]` on purpose: a
runtime override is a credential-exfiltration vector, which is why
ANTHROPIC_BASE_URL is excluded from the .env allowlist.
25 tests: pure policy (caps, jitter bounds, header parsing, HTTP-date
ignored rather than read as zero) plus scripted-server tests proving each
backend actually reaches the retry path, that a cancelled turn does not fire
its queued retry, and that `retry-after` is obeyed on the wire.
Co-Authored-By: Arch Linux <noreply@archlinux.org>
The cancellation test asserted the first request had landed after a flat 250ms, which a loaded CI runner can miss. Polling keeps the assertion but removes the timing assumption; the negative control (dropping the abort) still fails it. Co-Authored-By: Arch Linux <noreply@archlinux.org>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes cross-cutting item X.6 (429 retry/backoff) and a latent bug found while doing it (X.7).
The gap
There was no retry anywhere on the request path. 529 was handled — it falls back to a secondary model once — but 429 was not, and
retry-afterwas ignored entirely. A rate-limited user just got an error and lost the turn. Under enterprise load that is the failure mode you are guaranteed to hit.What landed
New
src/api/retry.rs:retry-afterverbatim (numeric seconds, plus theretry-after-msextension some OpenAI-compat providers send). No jitter, no exponential growth — the server named the time; second-guessing it just earns another 429.retry-afterabove which it fails fast with a clear message rather than freezing the UI for minutes.Wired into all three send sites: Anthropic streaming, Anthropic non-streaming, and OpenAI-compat (where Groq and OpenRouter throttle hardest).
Two deliberate scope limits, both documented in the module:
send_with_retryreturns the final response even on an error status, so the OpenAI-compat "does not support tools" 400 sniff and the 529 detection keep working byte-for-byte.The bug this uncovered
The TUI already had its own retry loop, which I had not seen when I scoped this. It re-issued
messages_streamon any error matchingreset by peer/connection/timed out.But
AppEvent::TextChunkdoesself.streaming.push_str(&chunk)and that buffer is never rewound. So a mid-stream connection reset replayed the whole response: the user saw a truncated answer followed by a complete one, and the doubled text was persisted into the session.Fixed with a
streamed_anyguard, extracted asretry::may_retry_streamso it is reachable from tests — the streaming loop itself is binary-only. 429/5xx were also removed from that loop now that the client owns them; keeping both meant 3 x 5 = 15 attempts that ignoredretry-after.Testing — 25 new tests
Split so each layer proves something the other cannot:
retry-aftercaps, jitter bounds and clamping, header parsing. Includes an HTTP-dateretry-afterbeing ignored rather than parsed as zero — that would turn a polite backoff into a hot retry loop.TcpListener, no new dependency): a 429 is retried then succeeds, a 400 is not retried, a permanent 429 stops at the cap, every retry notifies, and giving up explains why.messages_stream/messages/ the OpenAI-compat client actually reach the retry path. Each fails if that call is swapped back to a plainsend().retry-afteron the wire: the other server tests useretry-after: 0and would still pass if the header were ignored, so one test uses a 1s delay that is distinguishable from the 250-500ms exponential step.Every fix was verified by reintroducing the bug and confirming the test fails — the method used throughout this audit. All six reintroductions were caught; removing the attempt cap hung until timeout, which is the intended proof.
The
#[cfg(test)]base-URL override exists because a runtime override is a credential-exfiltration vector — the same reasonANTHROPIC_BASE_URLis excluded from the.envallowlist. It is not compiled into release builds.QA
--all-targets --all-features -D warnings)