Retries¶
A retry that repeats a write the server already processed charges a card twice; a retry storm turns a brief outage into a long one. headgate's retry layer re-sends a request only when that is safe (the request was not processed, or repeating it is harmless) and worthwhile (the failure is transient, the budget allows it, the deadline leaves time, and the origin's breaker is not refusing).
Configure it once with TransportPolicy(retry=RetryPolicy(...)); override it for a call with retry=RetryPolicy(...),
turn it off with retry=False, or set it per route with Route(fragment, retry=...).
from headgate import RetryPolicy, TransportPolicy
from headgate.retry import Backoff, Hedge, RetryBudget
policy = TransportPolicy(
retry=RetryPolicy(
attempts=4,
backoff=Backoff(factor=0.25, max_wait=8.0),
budget=RetryBudget(ratio=0.1),
hedge=Hedge(after=0.8),
)
)
RetryPolicy field |
Default | Meaning |
|---|---|---|
attempts |
3 |
sends per request, the first included (1 never retries); hedges count |
methods |
GET HEAD OPTIONS PUT DELETE |
the methods treated as idempotent |
statuses |
408 425 429 500 502 503 504 |
the statuses that are retryable failures |
backoff |
Backoff(factor=0.5, max_wait=5.0) |
the wait when the response names none |
max_retry_after |
30.0 |
the longest Retry-After that is waited |
budget |
RetryBudget(ratio=0.2, min_per_second=10, window=10) |
the share of traffic retries may add, or None |
respect_should_retry |
True |
follow a server's x-should-retry: true or false |
hedge |
None |
an opt-in Hedge for slow idempotent requests |
min_attempt_time |
0.05 |
the least time left before the deadline that an attempt or a wait starts with |
NO_RETRY is RetryPolicy(attempts=1), what retry=False installs.
What is re-sent¶
Each attempt's result is classified, in this order:
- An answer (any status not in
statuses, unless the server saysx-should-retry: true) is returned. - A failure re-sending cannot cure is never re-sent: a local mistake (
LocalProtocolError, such as a header value that cannot be encoded;UnsupportedProtocol;InvalidURL), and a redirect chain pastmax_redirects(TooManyRedirects), which the same request follows the same way again. - The server's word decides when it sends
x-should-retry: trueorfalse. - Not processed is retryable for every method: a
425 Too Early(RFC 8470), and an HTTP/2 stream the server reset withREFUSED_STREAM(RFC 9113). A GOAWAY covering streams the server never started is retried inside the connection pool, below this layer. - An idempotent method (
methods) or a request carrying an idempotency key is retryable. - Anything else (a POST or PATCH without a key) is retryable only when nothing of it was written. That is a
recorded fact, not a guess: a layer directly above the network tracks, per attempt, whether a connection was
obtained and how many request bytes went out. A
ConnectError,ConnectTimeoutorPoolTimeoutbefore the write began is safe; aReadTimeoutafter the body was sent is not. A custom transport that reports no progress is judged by the exception's type instead.
A request whose body is a one-shot stream (content= an async iterable) is never sent twice; the error notes why. Give
content= a factory instead to keep it retryable (see streaming).
The retry policy applies to each request the client sends: a followed redirect's hop, and a token-mint hop of a
CachedTokenAuth, each run their own sequence of attempts.
Backoff¶
Between attempts the layer waits factor · 2^n · jitter before retry n (full jitter: jitter is a uniform draw in
[0, 1)), capped at max_wait. With the defaults that is up to 1 s before the first retry and up to 2 s before the
second. The draw comes from the operating system's generator rather than random, so an application that seeds
random does not synchronize its processes' retries. A failed response is read (a bounded head of its body) and
closed before the wait, so no connection is held through it.
Retry-After¶
A 429's or 503's retry-after-ms or Retry-After (whole or fractional seconds, or an HTTP-date) replaces the backoff
and is waited exactly, not capped by max_wait, when it is at most max_retry_after. A longer one is not waited:
the call stops and raises RateLimitError (429) or UpstreamStatusError with retry_after set (and retryable as
below), so the caller can reschedule. The rate budget reads
the same header the same way, so the retry's wait and the budget's pause end together.
The retry budget¶
Retries may add at most ratio of the first attempts made over the last window seconds, plus a floor of
min_per_second · window so a quiet client can still retry: with the defaults, 20% plus 10 a second. Hedges spend the
same budget. A client keeps one account per distinct RetryBudget value, shared by every call it makes and by its
with_options views, so once traffic is above the floor a failing upstream sees about 1.2 times its normal load from
a client, not three times. A call that finds the budget
spent raises its last failure with the note retry budget exhausted. budget=None removes the cap.
Deadlines and the breaker¶
Each attempt's connect, read, write and pool timeouts are cut to the time left before the call's
deadline. No attempt, and no wait before one, starts with less than min_attempt_time left: the call
raises its last failure with the note deadline exhausted instead of sleeping past the deadline.
The retry layer checks the origin's circuit breaker before every retry and after every wait, and stops
with the note circuit opened once it refuses. A whole retry sequence counts once toward the breaker.
Hedging¶
A hedge sends a second copy of a slow idempotent request instead of waiting for it to time out. With
RetryPolicy(hedge=Hedge(after=0.5, max_extra=1)), a request that has not answered after 0.5 s is sent again beside
the first; the first answer wins, and the other send is cancelled and its response closed. A retryable failure from
one send leaves the others running. Hedges count as attempts, spend the retry budget, respect the deadline and the
breaker, and appear in events (RetryScheduled with reason hedge) and in the history (attempt 2/3 (hedge): ...).
Only methods are hedged, and only with a replayable body. A policy whose methods include POST or PATCH refuses a
hedge when it is constructed.
Idempotency keys¶
A POST is safe to retry once the server can recognize a re-send. TransportPolicy(idempotency=IdempotencyKeys())
stamps a fresh key on every POST and PATCH, under Idempotency-Key by default (IdempotencyKeys(header=...,
factory=...) changes either). A call can pass its own with idempotency_key=, and a key set in headers= is kept.
The key is stamped once per call, before the retry layer, so every attempt carries the same one, and a keyed request is
retried like an idempotent one.
What retryable means¶
ClientError.retryable is the retry policy's own verdict on the outcome that ended the call, by the rules above: it is
True exactly when the call's RetryPolicy would re-send after it. So a surviving 503 on a GET is retryable, while a
501 or 505 (outside statuses), a 503 the server marked x-should-retry: false, a 503 on a POST without an idempotency
key, and a redirect loop are not. The message follows the same rule for a surviving 429 or 5xx: it calls the status
transient when the policy counts it as a failure to retry (transient 503 for GET orders), and upstream when it
does not (upstream 501 for GET orders). A network failure while reading the body is judged as one in the send would
be: retryable for an idempotent or keyed request, not for a POST that was written in full. The flag ignores what stopped
the sequence (spent attempts, the budget, the deadline), since it answers whether sending again later could help.
Two errors set it otherwise: a RateLimitError from a rate budget paused past the deadline is
retryable after its retry_after, and a token mint on a client of its own, which no retry layer judged, is retryable for
any network failure but those re-sending cannot cure.
Attempt history¶
The error a call raises carries attempts, retryable, retry_after, and a note per attempt:
attempt 1/3: 503 Service Unavailable (Retry-After 2.0s) after 0.41s
attempt 2/3: ReadTimeout after 30.0s
attempt 3/3: 502 Bad Gateway after 0.12s
Notes name statuses and exception types only, never a URL, a header or a message, so they carry no credential.
str(error) stays one line; the notes print under the traceback.
Testing retries¶
headgate.testing.FaultyTransport plays a schedule of faults and records what each attempt sent. See
testing.