Repository navigation
Retry upstreams that fail transiently before any content - #122
Merged
Merged
Conversation
When every candidate route has been tried and the last failure is
transient (500/502/503/504/529, a connection that could not be made or
broke before the head, a stream that broke off or reported an overload
or server error before any content) and none of the answer reached the
client, wait 2, 4, 8, then 16 s (+-20%), or as long as the upstream's
retry-after-ms / Retry-After says, and send again to the routes that
failed that way. A single route, and a stream, go back to the same
route. Picks still go through the strategy, breaker, route caps,
affinity and the sealed-history pin.
- New system setting gateway.retry_wait_secs (default 120, 0-600,
0 disables), counted from the request's arrival; non-streaming
requests also stay within the gateway's 120 s request timeout.
- Streams hold the upstream's opening events until the first content
while a retry is possible, and send ": keep-alive" SSE comments after
15 s of silence while holding or waiting.
- gateway_provider_retry_total{provider}; "retries" on the log row.
- In-stream server_error maps to 500 like api_error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…for Gemini - Gemini clients never get ": keep-alive" SSE comments while a stream is held or waits to go out again; the google-genai SDK reads every SSE line as JSON. - A whole answer gets a hard deadline 2 s before the gateway port's 120 s request timeout, counted from a new arrival stamp layered just inside the TimeoutLayer. Each attempt is cut there and the last upstream error (or a 504 ProviderTimeout) is answered and logged instead of an empty 408; a retry starts only when its wait plus an attempt as long as the last one fit before it. - A zero retry-after-ms / Retry-After is ignored (the backoff applies) and shorter hints are held to 250 ms. - Deciding a stream retry clears what the failed attempt left for the tail, so a client that leaves during the wait is logged as cancelled without billing, and the failed attempt is not counted twice against the route. - A request that reqwest cannot build (an invalid base URL) is a ProviderError, not a transient NetworkError. 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
When every candidate route has been tried and the last failure is transient, and none of the answer has reached the client, the gateway now waits and sends the request again instead of returning the error.
NetworkError); a stream that broke off (transport) or reported an overload or server error (overloaded_error,api_error,server_error,server_is_overloaded,slow_down, GeminiUNAVAILABLE) before any content.retry-after-ms/Retry-Afteron the failed response wins (now kept onProviderHttpError::retry_after), except that a zero hint is ignored and shorter hints are held to 250 ms. A retry starts only if its wait ends before the deadline; an in-flight stream attempt is not cut.gateway.retry_wait_secs(default 120, 0–600, 0 disables), counted from the request's arrival. Seeded, validated in the settings handler, read with a clamp, editable under Settings → Gateway → Routing strategy (en/zh).Arrivedstamp is layered just inside the gateway port'sTimeoutLayer(its 120 s is now the shared constantproxy::REQUEST_TIMEOUT). Non-streaming attempts are cut 2 s before that timeout and the last upstream error (or a 504ProviderTimeout) goes through the normal error/log path instead of an empty 408; a retry starts only when its wait plus an attempt as long as the last one fit before it.Non-streaming (
select_route_with_failover)After a round exhausts the candidates, if the last error is transient and the wait fits, the loop sleeps and starts another round restricted to the providers whose last failure in this request was transient. Each pick still goes through
pick_admitted, so strategy, breaker, route caps, affinity and the sealed-history pin apply; each failed attempt is still recorded in health as before. A single-route model retries the same route.Streaming (
build_chat_pump)Streaming has no cross-route failover; the response head (200) is returned before the upstream is opened, as before.
OpenUpstreamis now a factory so the pump can open the same route again. AResendgate (routing.rs) checks the route's breaker (pinned-only conversations bypass it, as on the first pick) and its caps before each retry, and records the failed attempt in health.While a retry is still possible, the upstream's opening (
message_start/ping,response.created/in_progress, role-only Chat chunks, BedrockmessageStart) is held back on the raw upstream bytes (Opening, read withtw_dialect::convert::Reader) until the first content, so a retry never duplicates it. An error is classified before release: transient → retry; anything else → the held opening and the error go out as before. Content ahead of an error in the same chunk commits the stream. While holding or waiting, non-Gemini clients get: keep-aliveSSE comments after 15 s of silence (dropped by the WebSocket relay); Gemini clients never get them, since the google-genai SDK parses every SSE line as JSON. Deciding a retry clears what the failed attempt left for the stream's tail, so a client that leaves during the wait is logged as cancelled, unbilled, and the failed attempt is not counted twice against the route. Withretry_wait_secs = 0nothing is held and the pump behaves as before.Observability
gateway_provider_retry_total{provider}next togateway_provider_fallback_total.gateway_logsdetail carries"retries": non success and failure rows when a request was sent again.server_errornow maps to 500 (ProviderHttpError) likeapi_error, instead of 502 (ProviderError).ProviderErrorinstead ofNetworkError.Tests
proxy::retry); opening hold per dialect, error-before/after-content, hold cap, in-stream transient classification, keep-alive cadence with paused time, no keep-alives for Gemini, retry decision clears the failed attempt (lifecycle);Retry-After/retry-after-msparsing on 5xx and builder errors not transient (transport); setting validation.gateway_retry.rs, wiremock): buffered retry after 503s, backoff without a hint, 400/404/429 not retried,0disables, deadline stops retries, two routes both retried, stream retried when it cannot open, a zeroRetry-Afterstill waits out the backoff, keep-alives reach a Chat client but not a Gemini SSE client, stream retried after an in-stream overload before content (onemessage_startreaches the client), not retried after content, not retried on an in-stream refusal.analytics_clickhouse.rs:"retries"on the log row for a buffered and a streamed request.gateway.retry_wait_secs = 0so existing tests with failing upstreams keep their timing; the retry tests turn it on.🤖 Generated with Claude Code