Classifying Retryable Errors in Async Clients¶
A retry policy is only as good as the question it asks before each attempt: would doing this again help, and is doing it again safe? Most client code answers with except Exception: retry, which retries a 400 Bad Request forever and re-sends a payment after a read timeout. The correct answer needs two independent facts — whether the failure was transient, and whether the request can be repeated without duplicating an effect — and the second one is not a property of the error at all. This guide builds a classifier against real httpx failures, all of them produced by a live server, and covers the timeout case that makes the whole topic subtle.
Prerequisites¶
- Python 3.11+ with
httpx(pip install httpx). The taxonomy maps directly ontoaiohttpand most drivers. - Retry mechanics from Retry & Backoff Strategies and bounding from per-attempt and total timeouts.
- HTTP client basics from Async HTTP Clients & Servers.
1. Learn the exception hierarchy you are matching on¶
Guessing at exception names produces policies with holes. The verified httpx tree, exercised against a live server:
| failure | exception | inheritance |
|---|---|---|
| connection refused | ConnectError |
NetworkError → TransportError → RequestError |
| unroutable address | ConnectTimeout |
TimeoutException → TransportError |
| server too slow | ReadTimeout |
TimeoutException → TransportError |
| truncated response | RemoteProtocolError |
ProtocolError → TransportError |
| bad status code | HTTPStatusError |
HTTPError directly |
Two structural facts matter. Every timeout — connect, read, write and pool — derives from TimeoutException, so one except clause covers them while the policies for them differ. And HTTPStatusError sits outside RequestError: a response that arrived is not a transport failure, and its retryability is decided by the status code.
Note also that a DNS failure surfaces as ConnectError, not a distinct class — so "host does not exist" and "host refused the connection" are indistinguishable by type, which is one reason a retry budget matters more than perfect classification.
Verify: print type(exc).__mro__ for each failure your client sees in staging; policies built on guessed names miss cases.
2. Split transport failures by where they failed¶
The useful axis is not "network error versus timeout" but did the server possibly act on this request?
def transport_retryable(exc: Exception, idempotent: bool) -> bool:
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)):
return True # the request never left
if isinstance(exc, (httpx.ReadTimeout, httpx.RemoteProtocolError, httpx.ReadError)):
return idempotent # the server may have processed it
if isinstance(exc, (httpx.LocalProtocolError, httpx.UnsupportedProtocol, httpx.InvalidURL)):
return False # deterministic: our bug
return False
ConnectError and ConnectTimeout are unambiguous — no bytes reached the application — so they are safe to retry even for a POST. PoolTimeout is better still: it means your own client's connection pool was full, the request never started, and the retry costs the server nothing.
ReadTimeout is the interesting one. The request was sent; the response did not arrive in time. The server may have completed the work, or may be about to. Retrying a GET is free; retrying POST /payments may charge a customer twice. Classifying a read timeout as retryable regardless of method is the single most common way retry logic causes duplicates.
Verify: exercise each branch with a real failure — a closed port, a slow endpoint, a truncated response — and check the classification.
3. Decide idempotency from the request, not the error¶
RFC 9110 defines GET, HEAD, OPTIONS, TRACE, PUT and DELETE as idempotent; POST and PATCH are not. That default is a starting point, not the answer — a PUT that appends to a list is not idempotent, and a POST carrying an idempotency key is:
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"}
def is_idempotent(request) -> bool:
return request.method in SAFE_METHODS or "Idempotency-Key" in request.headers
The key is a client-generated unique value the server stores with the result, so a repeat returns the original outcome rather than performing the work again. Stripe, Square and most payment APIs require it precisely because their clients cannot otherwise retry safely. If you own the server, supporting an idempotency key converts the whole class of "unknown outcome" failures into retryable ones — a much bigger win than any client-side cleverness.
Classified over the verified matrix:
| failure | POST |
POST + key |
GET |
|---|---|---|---|
ConnectError |
retry | retry | retry |
PoolTimeout |
retry | retry | retry |
ReadTimeout |
no | retry | retry |
RemoteProtocolError |
no | retry | retry |
LocalProtocolError |
no | no | no |
Verify: for each non-idempotent endpoint you call, either an idempotency key is sent or read timeouts are not retried.
4. Classify status codes by what the server is telling you¶
A response that arrived carries an explicit statement about whether to try again:
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
def status_retryable(response, idempotent: bool) -> bool:
if response.status_code not in RETRYABLE_STATUS:
return False
if response.status_code in (429, 503):
return True # explicit back-pressure: obey it
return idempotent
429 and 503 are the server asking you to come back later, and they are safe to retry for any method because the request was rejected rather than processed. 500 and 502 are ambiguous — a 500 from a handler that already wrote to the database has had an effect — so they follow the idempotency rule. Everything in the 4xx range other than 408, 425 and 429 is deterministic: retrying a 422 produces another 422 and wastes the budget.
409 Conflict deserves a note: it is sometimes the correct response to a duplicate submission, which means a retry already succeeded. Treat it as success for idempotent writes rather than retrying it.
Verify: a request that returns 400 makes exactly one attempt, and one that returns 503 retries.
5. Obey Retry-After¶
When a server sends Retry-After, it is better information than any backoff formula. It comes in two forms, and code that handles only one sleeps for the wrong duration:
def retry_after_seconds(value: str | None, now: float | None = None) -> float | None:
if value is None:
return None
value = value.strip()
if value.isdigit():
return float(value) # delta-seconds
parsed = email.utils.parsedate_to_datetime(value) # HTTP-date
if parsed is None:
return None
return max(0.0, parsed.timestamp() - (now or time.time()))
Verified: "2" parses to 2.0 seconds, an HTTP-date 30 seconds in the future parses to 30, and a date in the past clamps to 0.0 rather than returning a negative sleep.
Then clamp it. A server asking for 300 seconds inside a 5-second request budget means "give up", not "hold the request for five minutes":
delay = retry_after_seconds(response.headers.get("retry-after"))
if delay is None:
delay = backoff(attempt)
if delay > remaining_budget():
raise RetriesExhausted("Retry-After exceeds the remaining budget")
Verify: both header forms produce the same sleep, and a value beyond the budget fails fast instead of sleeping.
Verification¶
A classifier is trustworthy when:
- It matches on verified exception types, with the hierarchy checked against the library version in use.
- Idempotency comes from the request, not from the failure.
- Connect and pool failures always retry; read timeouts retry only when repetition is safe.
- 4xx statuses are terminal apart from 408, 425 and 429.
Retry-Afteris honoured in both forms and clamped to the remaining budget.- Every decision is observable: a metric labelled by exception type and outcome.
Pitfalls & edge cases¶
except Exceptionaround the call. It catchesInvalidURLand bugs in your own code, retrying deterministic failures until the budget is gone.- Retrying
CancelledError. It derives fromBaseExceptionin 3.8+, but code catchingBaseExceptionfor logging must re-raise; retrying a cancellation defeats shutdown. - Treating any 5xx as retryable.
501 Not Implementedand505are permanent. - Retrying POST on a read timeout without a key. The canonical duplicate-charge bug.
- Ignoring the pool.
PoolTimeoutunder load means your own client is the bottleneck — raise the pool size rather than retrying into the same queue. - Classifying without a budget. Even perfect classification amplifies load during a brownout; pair it with retry budgets.
Frequently Asked Questions¶
Which HTTP errors should be retried?
Transport failures that never reached the server — connect errors, connect timeouts and pool timeouts — always. Read timeouts and 5xx responses only when the request is idempotent or carries an idempotency key. Statuses 408, 425, 429, 500, 502, 503 and 504 are candidates; every other 4xx is deterministic and must not be retried.
Is it safe to retry a POST request after a timeout?
Not by default. A read timeout means the outcome is unknown — the server may have processed the request. Retrying duplicates the effect. Send an Idempotency-Key header that the server honours, and then a retry is safe; without one, surface the uncertainty to the caller.
What is the difference between httpx ConnectTimeout and ReadTimeout?
ConnectTimeout means the connection could not be established, so the request never reached the server and is always safe to retry. ReadTimeout means the request was sent but no response arrived in time, so the server may have acted — retry it only for idempotent requests.
How do I parse the Retry-After header?
It is either delta-seconds or an HTTP-date. Handle both: if the value is all digits, use it as seconds; otherwise parse it with email.utils.parsedate_to_datetime and subtract the current time, clamping negatives to zero. Then clamp the result to your remaining request budget.
Should a 429 be retried on a POST?
Yes. A 429 means the request was rejected before processing rather than performed, so repeating it cannot duplicate an effect. Honour the Retry-After header if present, and count the retry against your retry budget like any other.
Related¶
- Retry & Backoff Strategies — up to the topic overview.
- Implementing retry budgets — capping the amplification that classification permits.
- Async HTTP Clients & Servers — client and pool configuration.
- Resilience, Cancellation & Error Handling — the section overview.