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¶
- Python 3.11+ and
httpx(pip install httpx); examples usehttpx.MockTransportso they run without a network. - Client structure from Async HTTP Clients & Servers and timeouts from setting connect, read and total timeouts in async HTTP clients.
- Retry discipline from exponential backoff with jitter in asyncio and idempotency keys for safe async retries.
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.
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.
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 — not4xxclient errors. Retry-Afteris 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
503response holds a pooled connection; a few retries exhaust the pool. - Retrying
4xx. A400or422will fail identically on every attempt and wastes the budget; a401may 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.
Related¶
- Async HTTP Clients & Servers — up to the topic overview for client configuration and lifecycle.
- Classifying retryable errors in async clients — deciding which failures belong in the retry set.
- Network I/O & Protocol Handling — the section overview for HTTP, pooling and protocols.