Skip to content

Retrying httpx Requests Safely

httpx.AsyncHTTPTransport(retries=3) looks like the retry feature every client needs, and teams enable it and move on. It retries connection establishment only: DNS failures and refused or reset connects before a request is sent. A 503 from the upstream, a 429 with Retry-After, a read timeout halfway through a response — none of those are retried, because httpx deliberately refuses to guess whether replaying your request is safe. That decision belongs to you, and it is not a detail: replaying a POST /payments after a read timeout can charge a customer twice. This guide adds retries at the right layer — a custom transport, so every call through the client benefits — while keeping the safety rules explicit: only idempotent or keyed requests, only congestion-shaped failures, Retry-After honoured, the request deadline respected, and streaming responses handled correctly.

Prerequisites

Where a retry can live 4 stacked layers from Caller / application to Network. Where a retry can live Caller / application sees one call handles final failure Retry transport 429, 502, 503, 504 connect and read errors Retry-After, backoff httpx transport retries= connects only pool and timeouts Network DNS, TCP, TLS the failures being retried Put the policy in the transport: every call through the client inherits it.

1. Know what the built-in retries cover

The retries argument exists and is useful — for exactly one class of failure.

# pip install httpx
import httpx

transport = httpx.AsyncHTTPTransport(retries=3)              # connection attempts only
client = httpx.AsyncClient(transport=transport, timeout=httpx.Timeout(5.0))

# Retried by httpx:      ConnectError, ConnectTimeout — before the request is sent
# NOT retried by httpx:  429/503/504 responses, ReadTimeout, RemoteProtocolError mid-response

The distinction is about safety. A connection that was never established cannot have reached the server, so retrying it can duplicate nothing. Everything after that point may have been received and acted upon, so httpx leaves the decision to the application. Enable retries for the cheap win, then add the rest yourself.

Verify: a request to an unreachable port fails after the configured number of connection attempts, while a mock returning 503 is not retried at all.

2. Put retries in a transport, not at every call site

A transport wrapper sees every request the client makes, including those issued by libraries built on your client, and it can retry without the caller knowing. The rules live in one reviewable place.

import asyncio
import httpx


class RetryTransport(httpx.AsyncBaseTransport):
    RETRY_STATUS = {429, 502, 503, 504}
    IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"}
    RETRY_EXCEPTIONS = (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError)

    def __init__(self, next_transport: httpx.AsyncBaseTransport, attempts: int = 3,
                 base_delay: float = 0.1) -> None:
        self._next = next_transport
        self._attempts = attempts
        self._base = base_delay

    def _may_replay(self, request: httpx.Request) -> bool:
        return request.method in self.IDEMPOTENT or "Idempotency-Key" in request.headers

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        last_exc: BaseException | None = None
        for attempt in range(1, self._attempts + 1):
            try:
                response = await self._next.handle_async_request(request)
            except self.RETRY_EXCEPTIONS as exc:
                last_exc = exc
                if not self._may_replay(request) or attempt == self._attempts:
                    raise
                await asyncio.sleep(self._base * 2 ** attempt)
                continue
            if response.status_code not in self.RETRY_STATUS or attempt == self._attempts:
                return response
            if not self._may_replay(request):
                return response                               # hand the error to the caller
            await response.aclose()                           # release the connection first
            await asyncio.sleep(self._retry_delay(response, attempt))
        raise last_exc                                        # unreachable with attempts >= 1

    def _retry_delay(self, response: httpx.Response, attempt: int) -> float:
        header = response.headers.get("retry-after", "")
        if header.isdigit():
            return float(header)                              # the server's instruction wins
        return self._base * 2 ** attempt

Closing the response before retrying matters: an unread response body holds its connection out of the pool, and a retry loop that forgets it exhausts the pool within a few failures — the exhaustion signature described in diagnosing connection pool exhaustion in async clients.

Verify: with a mock that returns 503 twice and then 200, one client.get() returns 200 after three transport attempts.

3. Refuse to replay what must not be replayed

The _may_replay check is the safety boundary. Non-idempotent requests are retried only when they carry an idempotency key, which makes replay safe on the server side.

import asyncio
import httpx


async def main() -> None:
    def refuse(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectError("connection refused")

    transport = RetryTransport(httpx.MockTransport(refuse), attempts=3, base_delay=0.01)
    async with httpx.AsyncClient(transport=transport) as client:
        try:
            await client.post("https://api.example/payments", json={"amount": 1000})
        except httpx.ConnectError:
            print("POST without a key: failed immediately, not retried")

        try:
            await client.post("https://api.example/payments", json={"amount": 1000},
                              headers={"Idempotency-Key": "pay-7f3c"})
        except httpx.ConnectError:
            print("POST with a key: retried, then surfaced")

        try:
            await client.get("https://api.example/orders/7")
        except httpx.ConnectError:
            print("GET: retried, then surfaced")


asyncio.run(main())

All three end in an error because the upstream is unreachable, but only two of them were attempted more than once. That difference is the whole point: a connect error usually means nothing was sent, but "usually" is not a guarantee once a request has left the socket, and the key removes the need to reason about it.

Verify: instrument the mock with a counter — the plain POST records one attempt, the keyed POST and the GET record three each.

May this request be replayed? A decision on What is the request and the failure with 3 outcomes. May this request be replayed? What is the request and the failure? GET, HEAD, PUT, DELETE retry idempotent by spec POST with an idempotency key retry server deduplicates POST without a key, or 4xx do not retry surface to the caller The transport must decide this per request, not per client.

4. Keep retries inside the request's deadline

Retries multiply latency. Three attempts with a five-second timeout each can take fifteen seconds under a one-second product budget. Bound the whole sequence with the caller's deadline, and stop retrying when there is no time left to succeed.

import asyncio
import httpx


class DeadlineAwareRetryTransport(RetryTransport):
    """Stops retrying when the remaining request budget cannot fit another attempt."""

    def __init__(self, next_transport, attempts=3, base_delay=0.1, per_attempt: float = 1.0):
        super().__init__(next_transport, attempts, base_delay)
        self._per_attempt = per_attempt

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        loop = asyncio.get_running_loop()
        budget = request.extensions.get("deadline_s")          # set by the caller, see below
        deadline = loop.time() + budget if budget else None
        for attempt in range(1, self._attempts + 1):
            if deadline is not None and deadline - loop.time() < self._per_attempt:
                raise httpx.ReadTimeout("no budget left for another attempt", request=request)
            try:
                response = await self._next.handle_async_request(request)
            except self.RETRY_EXCEPTIONS:
                if not self._may_replay(request) or attempt == self._attempts:
                    raise
            else:
                if response.status_code not in self.RETRY_STATUS or attempt == self._attempts \
                        or not self._may_replay(request):
                    return response
                await response.aclose()
            delay = self._base * 2 ** attempt
            if deadline is not None:
                delay = min(delay, max(0.0, deadline - loop.time()))
            await asyncio.sleep(delay)
        raise httpx.ReadTimeout("retries exhausted", request=request)


async def main() -> None:
    calls = {"n": 0}

    def slow_503(request: httpx.Request) -> httpx.Response:
        calls["n"] += 1
        return httpx.Response(503)

    transport = DeadlineAwareRetryTransport(httpx.MockTransport(slow_503), attempts=5,
                                            base_delay=0.05, per_attempt=0.2)
    async with httpx.AsyncClient(transport=transport) as client:
        try:
            await client.get("https://api.example/x", extensions={"deadline_s": 0.35})
        except httpx.ReadTimeout as exc:
            print(f"gave up after {calls['n']} attempts: {exc}")


asyncio.run(main())

Passing the budget through request.extensions keeps the transport decoupled from how the application stores deadlines; a client-level event hook can fill it in from a context variable, as in propagating deadlines with contextvars. Without such a bound, the retry logic silently overrides the timeout the caller chose.

Verify: the call stops after two or three attempts — as many as fit in the 350 ms budget — rather than the configured five.

5. Handle streaming responses correctly

Retries and streaming interact badly if the response body has already been handed to the caller. The transport must only retry before returning the response object; once the caller starts iterating the body, a failure belongs to the caller.

import asyncio
import httpx


async def main() -> None:
    attempts = {"n": 0}

    def flaky(request: httpx.Request) -> httpx.Response:
        attempts["n"] += 1
        if attempts["n"] < 3:
            return httpx.Response(503, headers={"Retry-After": "0"})
        return httpx.Response(200, content=b"chunk-1chunk-2chunk-3")

    transport = RetryTransport(httpx.MockTransport(flaky), attempts=4, base_delay=0.01)
    async with httpx.AsyncClient(transport=transport) as client:
        async with client.stream("GET", "https://api.example/export") as response:
            print("stream status", response.status_code, "after", attempts["n"], "attempts")
            body = b"".join([chunk async for chunk in response.aiter_bytes()])
    print("streamed", len(body), "bytes")


asyncio.run(main())

The retries happened while establishing the response, so the caller's async with client.stream(...) saw only the successful one. A failure that occurs during iteration cannot be retried transparently: the caller has already consumed part of the body, and resuming needs a Range request or a restart of the whole download, which only the caller can decide — the pattern in streaming large responses with httpx.

Verify: the stream reports 200 after three transport attempts, and the body arrives complete.

Which outcomes belong in the retry set A grid of 5 rows by 2 columns. Which outcomes belong in the retry set outcome retry? why ConnectError / ConnectTimeout yes nothing was sent 429, 503 with Retry-After yes, after the delay the server said when 502, 504 yes, if replayable gateway-level failure ReadTimeout only if replayable may have been processed 400, 401, 404, 422 no identical result next time Retrying the last row burns the budget and hides the real error.

Verification

httpx retries are configured safely when:

  • Built-in and custom retries are distinguished: retries= covers connects; response and read failures are handled by your transport.
  • Only safe requests are replayed: non-idempotent requests without an idempotency key are never retried.
  • Only congestion-shaped failures retry: 429, 502, 503, 504, connect and read errors — not 4xx client errors.
  • Retry-After is honoured when present, with jittered exponential backoff otherwise.
  • Deadlines hold: total time including retries stays within the caller's budget, and responses are closed before each retry.

Pitfalls & edge cases

  • Retrying without closing the response. An unclosed 503 response holds a pooled connection; a few retries exhaust the pool.
  • Retrying 4xx. A 400 or 422 will fail identically on every attempt and wastes the budget; a 401 may need a token refresh, not a retry.
  • Fixed delays across a fleet. Every client retrying after exactly one second creates a synchronised second wave; add jitter, as covered in the backoff guide.
  • Double retry layers. A retrying transport under a retrying application loop multiplies attempts: three by three is nine requests. Keep retries in one layer.
  • Non-idempotent PUT/DELETE. They are idempotent by specification, but some APIs implement them with side effects; verify before relying on the default allow-list.

Frequently Asked Questions

Does httpx retry failed requests automatically?

Only connection attempts. httpx.AsyncHTTPTransport(retries=n) retries failures that happen while establishing a connection, such as DNS errors and refused connects. Responses with status 429, 502, 503 or 504, read timeouts and protocol errors after the request was sent are never retried automatically, because replaying them may not be safe.

How do I add retries to httpx for 429 and 503 responses?

Wrap a transport: subclass httpx.AsyncBaseTransport, call the inner transport in a loop, and retry when the status is in your retryable set. Close the response before retrying so its connection returns to the pool, honour the Retry-After header when present, and use jittered exponential backoff otherwise.

Is it safe to retry a POST request?

Not by default, because the server may have processed the first attempt. Retry a POST only when it carries an idempotency key that lets the server recognise the replay, or when the endpoint is documented as idempotent. GET, HEAD, PUT, DELETE and OPTIONS are safe to retry by specification.

How do retries interact with httpx timeouts?

They multiply: each attempt gets the configured timeout, so three attempts can take three times as long. Pass the caller's remaining budget into the transport, skip further attempts when the budget cannot fit one, and cap backoff delays by the remaining time.