Repository navigation
Retry the last upstream after a temporary failure before any content - #373
Merged
Merged
Conversation
When every candidate has been tried and the last attempt failed temporarily before any answer content reached the client (5xx or overload, an overload or server error reported in the stream, a connection that could not be made or broke before the response head, a stream that broke before content), the gateway now backs off and runs another round over the candidates whose failure was temporary, instead of passing the error on. - Backoff 2, 4, 8 s, then 16 s each time, +-20% jitter; retry-after-ms / Retry-After from the failed response replace it. - Bounded by the request's wait deadline (failover.wait_secs, shared with queueing): retrying stops when the next pause would end after it, and the last error is handed over. wait_secs: 0 disables retrying. - Rate limits, used-up quotas, no balance, rejected credentials, missing models, request errors (including in-stream invalid_request_error) and idle timeouts are not retried. Token counting requests are not retried. - A conversation that stays on an upstream (affinity, sealed history) retries only that upstream; cooling candidates are skipped unless all of them are. - Each retry is a normal hop on the attempt chain. Aborting during the backoff ends the request without another hop. - The last candidate's opening is now held whenever the request can still wait, so an error before content can be retried (and an in-stream 429 on the last candidate always reaches the throttle queue). The 15 s early head and keep-alives are unchanged; a final failure after the head is written in the client's format. Throttles::wary is removed. Control protocol 53: new Event::RequestRetrying (paired with RequestDequeued, kept in InFlightRequest::events); AttemptView gains retry and backoff_ms. Tests that exercise pass-through of the last upstream's error, or the unheld last stream, now set wait_secs: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cheduled Refused connections take a while to fail on Windows, so the test no longer guesses when the first attempt fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…swer unheld - The upstream's retry-after-ms / Retry-After can only lengthen the backoff, never shorten it. - Same-upstream retry only for 500, 502, 503, 504 and 529, and not when the error body names a request error. Other 5xx still fail over as before. - In-stream errors are retried only for listed kinds (server_error, api_error, overloaded_error, server_is_overloaded, slow_down, Gemini UNAVAILABLE / INTERNAL and 5xx numeric codes, Bedrock serviceUnavailableException / internalServerException), in response.failed and in the top-level Responses error event alike. Unlisted codes still fail over as before. - The last candidate's opening is held again only for streamed answers: when the client receives an SSE stream (early head and keep-alives), or when the upstream may be rate limited (Throttles::wary, restored). Non-streamed answers from the last candidate are passed on as they come. - Before discarding the last candidate's own in-stream error, check that a retry can actually happen (time left for the next wait, pinned upstream); otherwise hand the upstream's response over unchanged. - A stream that ends cleanly after only opening events counts as broken before content (new code gw.upstream.stream_ended_early). - Hits in plugin-written content (SecretsFound, ContentMatched) are reported once per request, not once per attempt. - Log retried requests as retried rather than failed over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
What this changes
When every candidate has been tried and the last attempt failed temporarily before any answer content reached the client, the gateway backs off and tries again within the request's wait deadline instead of passing the error to the client. With a single upstream that means resending to it.
Example: a Responses/Codex upstream answers
200, then reports{"type":"error","code":"server_is_overloaded"}in the stream before any content. Before this change, if it was the last (or only) candidate, that error went straight to the client.Rules
invalid_request_error); in-stream errors whose code or type is on an allowlist (server_error,api_error,overloaded_error,server_is_overloaded,slow_down, GeminiUNAVAILABLE/INTERNALor a 5xx numeric code, BedrockserviceUnavailableException/internalServerException), inresponse.failedand in the top-level Responseserrorevent; a connection that cannot be made or breaks before the response head; a stream that breaks, or ends cleanly after only opening events, before content (new codegw.upstream.stream_ended_early). Not retried: other 5xx (501, 505, Cloudflare 52x …), unlisted in-stream codes (invalid_image,content_filter,invalid_encrypted_content…), rate limits (they keep using the throttle queue), used-up quota, no balance, rejected credentials, missing model, idle timeouts, and failures after content. Failover to another candidate is unchanged for all of these.retry-after-ms/Retry-After(or the body's retry delay) can lengthen a pause but never shorten it.failover.wait_secs, default 120 s, shared with queueing). Retrying stops when the next pause would end after it; the last error is handed over.wait_secs: 0disables retrying. An attempt already in flight is not cut by the deadline.Opening hold
The last candidate's streamed answer is now also held when the client receives an SSE stream and the request can still wait (deadline not passed); before, it was held only when the upstream may be rate limited (
Throttles::wary, which still applies). A held error before content can then be retried, and an in-stream 429 on such a stream reaches the throttle queue. The existing 15 s rule is unchanged: after 15 s the client gets200and: keep-alivecomments. Non-streamed answers from the last candidate are not held (their non-2xx statuses are still classified and retried). Before the last candidate's own in-stream error is discarded for a retry, the same check as the retry decision runs (time left for the next pause, pinned upstream); when a retry cannot happen, the upstream's response is passed on unchanged.Hits in plugin-written content (
SecretsFound,ContentMatched) are reported once per client request, not once per attempt.Control protocol 53
Event::RequestRetrying { id, upstreams, attempt, retry_at_ms, until_ms, at_ms }(tagrequest_retrying), emitted when a backoff starts;RequestDequeuedis emitted when it ends. Both are kept inInFlightRequest::events; the recorder ignores them.AttemptViewgainsretry(1-based retry number on every hop of a retry round) andbackoff_ms(pause before the first hop of that round, not part ofms), both optional.No config schema change and no request-DB change. Docs for
failover.wait_secsand the failover section are updated.How it was verified
crates/tw-gateway/tests/retry.rs(23 tests, fake upstreams, backoff scaled down viaAppState::retry_backoffwith jitter off): in-stream overload before content then success (Anthropic and Responsesserver_is_overloaded); immediate 503/502 retried twice; connect failure until the upstream comes up; stream broken before content; error after content not retried; 400/401/402/404/quota not retried; in-streaminvalid_encrypted_contentnot retried; deadline reached hands over the last error;wait_secs: 0; a longerretry-after-mshonoured and a 5 ms one not shortening the backoff;Retry-Afterbeyond the deadline gives up at once; 501, 524 and a 500 naming a request error not retried; an unlisted in-stream code not retried but still failing over; a stream ending after its opening retried; an in-stream error that cannot be retried in time handed over as the upstream sent it; the last whole answer not held; abort during backoff; two overloaded candidates retried in a second round; affinity retries only the pinned upstream;RequestRetrying/RequestDequeuedpairing andAttemptViewfields; held stream past 15 s gets keep-alives and either the retried answer or the final error in the client's format.plugins_security.rs: a retry does not report the plugin-written key or content hit twice.wait_secs: 0. The sealed-history test now checks that retries stay on the sealing upstream.cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace,cargo clippy -p tw-api --features ts,cargo test -p tw-api --features ts, and the TS export.Notes for review
🤖 Generated with Claude Code