Skip to content

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

Two questions decide every retry A grid of 4 rows by 2 columns. Two questions decide every retry failure safe to repeat not safe to repeat never reached the server retry retry: nothing happened may have been processed retry needs an idempotency key server rejected the input do not retry do not retry our own bug fix it fix it A timeout is the hard case: it means "unknown", not "failed".

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 NetworkErrorTransportErrorRequestError
unroutable address ConnectTimeout TimeoutExceptionTransportError
server too slow ReadTimeout TimeoutExceptionTransportError
truncated response RemoteProtocolError ProtocolErrorTransportError
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.

The httpx exception hierarchy you match on 5 stacked layers from HTTPError to HTTPStatusError. The httpx exception hierarchy you match on HTTPError the root of everything httpx raises too broad to catch for retries RequestError -> TransportError the request failed to complete this is the retry family TimeoutException ConnectTimeout, ReadTimeout WriteTimeout, PoolTimeout NetworkError ConnectError, ReadError WriteError, CloseError HTTPStatusError a response arrived, bad status classify by status code Verified against httpx 0.28: every timeout class derives from TimeoutException.

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.

Classifying an httpx failure A decision on Where did it fail with 3 outcomes. Classifying an httpx failure Where did it fail? ConnectError, ConnectTimeout always retry the request never started ReadTimeout, RemoteProtocolError retry if idempotent the server may have acted LocalProtocolError, InvalidURL never retry deterministic, our bug PoolTimeout is a local queue, not the server: always safe to retry.

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.

Honouring Retry-After 5 stages from 429 or 503 to sleep, then retry. Honouring Retry-After 429 or 503 server pushes back read Retry-After seconds or a date parse both forms past dates clamp to 0 clamp to the budget never sleep past it sleep, then retry backoff is overridden A server that tells you when to come back is the best signal you will get.

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-After is 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 Exception around the call. It catches InvalidURL and bugs in your own code, retrying deterministic failures until the budget is gone.
  • Retrying CancelledError. It derives from BaseException in 3.8+, but code catching BaseException for logging must re-raise; retrying a cancellation defeats shutdown.
  • Treating any 5xx as retryable. 501 Not Implemented and 505 are permanent.
  • Retrying POST on a read timeout without a key. The canonical duplicate-charge bug.
  • Ignoring the pool. PoolTimeout under 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.