Retry the operation, not every failure
An HTTPX retry policy should decide more than how many times to repeat
client.get().
HTTPX has a small built-in transport retry mechanism for connection failures.
It retries ConnectError and ConnectTimeout, but it does not provide a full
application policy for read or write failures, HTTP 429 and 5xx responses,
response validation, backoff, idempotency, or the total operation deadline.
Those decisions need context. A failed GET can usually be repeated. A timed-out
POST may already have changed remote state. A 429 should respect
Retry-After, while a 400 normally should not be retried at all.
A safe HTTPX retry policy therefore needs four boundaries:
- a narrow set of retryable failures;
- proof that another attempt is safe;
- bounded attempts with backoff and jitter;
- one elapsed-time budget for the complete operation.
This guide targets Python 3.13 and the stable HTTPX 0.28.1 release. The
transport, timeout, exception, client pooling, and MockTransport behavior was
verified against the official documentation and executable examples on August
7, 2026.
What HTTPX retries for you
HTTPX exposes connection retries through its transport:
import httpx
transport = httpx.AsyncHTTPTransport(retries=1)
client = httpx.AsyncClient(
transport=transport,
timeout=httpx.Timeout(
10.0,
connect=3.0,
read=10.0,
write=10.0,
pool=2.0,
),
)
The synchronous equivalent uses HTTPTransport.
This mechanism retries only ConnectError and ConnectTimeout. It does not
react to a 503 response, retry a stalled response body, or validate whether
another attempt is safe for the HTTP method.
| Failure | Retry by default? | Why |
|---|---|---|
ConnectTimeout |
Usually | No usable connection was established |
ConnectError |
Sometimes | It may be transient, but TLS or configuration errors are not |
ReadTimeout or ReadError |
Only when replay is safe | The server may already have processed the request |
WriteTimeout or WriteError |
Only when replay is safe | Part of the request may have been sent |
429, 502, 503, 504 |
Often, within budget | The response is explicit and may include retry guidance |
Other 4xx |
Usually not | Another attempt rarely changes an invalid request |
| Invalid response data | By explicit policy | A schema failure is not the same as a network failure |
PoolTimeout |
Usually not immediately | The caller may be saturating its own connection pool |
For the lower-level difference between connect and read failures, see What Python Requests Timeouts Mean for Connect, Read and Failure Modes. HTTPX also has write and pool timeout phases, but the same principle holds: a timeout limits waiting and does not prove what happened remotely.
Safety comes from the method and the operation
GET and HEAD are normally safe to retry. PUT and DELETE are defined as
idempotent, provided the endpoint preserves those semantics.
POST needs stronger evidence. If the operation creates a payment, order, job,
or AI generation, reuse one idempotency key across every attempt.
headers = {
"Idempotency-Key": operation_id,
}
The API that owns the effect must still enforce the key, detect payload mismatches, coordinate concurrent requests, and replay the original result.
Building Idempotency in REST APIs with Keys, Concurrency and Safe Retries covers that server-side contract.
Engineering Notes
Finding this useful?
Get the next one, along with occasional project updates and engineering notes.
A small async retry implementation
The implementation below keeps classification, replay safety, Retry-After,
jitter, attempts, and elapsed time in one place. It reuses the supplied
AsyncClient rather than creating a new connection pool for every attempt.
from __future__ import annotations
import asyncio
import random
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import httpx
IDEMPOTENT_METHODS = frozenset(
{"GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"}
)
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3
max_elapsed_seconds: float = 15.0
base_delay_seconds: float = 0.25
max_delay_seconds: float = 3.0
retry_statuses: frozenset[int] = frozenset(
{429, 502, 503, 504}
)
def _is_replay_safe(method: str, idempotency_key: str | None) -> bool:
return method in IDEMPOTENT_METHODS or idempotency_key is not None
def _retry_after(response: httpx.Response) -> float | None:
value = response.headers.get("Retry-After")
if value is None:
return None
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=UTC)
return max(
0.0,
(retry_at - datetime.now(UTC)).total_seconds(),
)
def _full_jitter(policy: RetryPolicy, attempt: int) -> float:
ceiling = min(
policy.max_delay_seconds,
policy.base_delay_seconds * 2 ** (attempt - 1),
)
return random.uniform(0.0, ceiling)
async def request_with_retry(
client: httpx.AsyncClient,
method: str,
url: str,
*,
policy: RetryPolicy = RetryPolicy(),
idempotency_key: str | None = None,
**kwargs: object,
) -> httpx.Response:
method = method.upper()
replay_safe = _is_replay_safe(method, idempotency_key)
headers = dict(kwargs.pop("headers", {}) or {})
if idempotency_key is not None:
headers["Idempotency-Key"] = idempotency_key
loop = asyncio.get_running_loop()
deadline = loop.time() + policy.max_elapsed_seconds
last_error: BaseException | None = None
for attempt in range(1, policy.max_attempts + 1):
remaining = deadline - loop.time()
if remaining <= 0:
break
retry_after: float | None = None
try:
async with asyncio.timeout(remaining):
response = await client.request(
method,
url,
headers=headers,
**kwargs,
)
except asyncio.CancelledError:
raise
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
last_error = exc
except (
httpx.ReadTimeout,
httpx.ReadError,
httpx.WriteTimeout,
httpx.WriteError,
) as exc:
if not replay_safe:
raise
last_error = exc
else:
if response.status_code not in policy.retry_statuses:
return response
if not replay_safe:
return response
last_error = httpx.HTTPStatusError(
"Retryable HTTP response",
request=response.request,
response=response,
)
retry_after = _retry_after(response)
if attempt == policy.max_attempts:
assert last_error is not None
raise last_error
delay = (
retry_after
if retry_after is not None
else _full_jitter(policy, attempt)
)
remaining = deadline - loop.time()
if delay >= remaining:
break
await asyncio.sleep(delay)
raise TimeoutError("HTTP retry budget exhausted") from last_error
This assumes the request body can be replayed, such as JSON or immutable bytes. A one-shot async stream cannot be sent again unless the application can rebuild it.
A production classifier may also inspect the cause of ConnectError so it does
not retry permanent certificate or configuration failures.
Backoff is protection for the downstream
Immediate retries synchronize callers around the same incident. If a dependency fails for one thousand requests and every caller retries immediately, the retry layer can turn a partial outage into a recovery-blocking load spike.
Exponential backoff spreads later attempts. Full jitter avoids sending every caller at the same powers-of-two intervals.
Retry-After should override locally calculated backoff for 429 and 503
responses, but it still belongs inside the operation deadline. If the provider
asks the client to wait longer than the remaining budget, fail or defer the work
instead of silently extending user-visible latency.
Timeouts and cancellation are separate decisions
HTTPX distinguishes connect, read, write, and pool timeouts. They bound inactivity in transport phases, not the complete retry operation.
The manual example adds max_elapsed_seconds above those phases. The
asyncio.timeout() block also cancels an in-flight attempt when the operation
budget expires.
Cancellation must propagate. Never catch BaseException, and explicitly
re-raise asyncio.CancelledError when a broader framework wrapper might
otherwise intercept it.
Reuse one scoped Client or AsyncClient. Creating a new client inside the
retry loop discards connection pooling and makes every attempt pay for fresh
connection setup. It also hides whether failures come from the dependency or
from local pool churn.
The interaction between SDK-level and application-level retry is shown in OpenAI Timeouts in Python for Retries, Streaming and Long-Running Responses.
Keep the policy above HTTPX with RelPrim
HTTPX should remain responsible for HTTP. RelPrim can own the wider operation policy above it.
import httpx
from relprim import (
ExponentialBackoff,
RetryPolicy,
TimeoutPolicy,
async_operation,
)
class RetryableHttpStatus(RuntimeError):
pass
async def load_inventory(
client: httpx.AsyncClient,
product_id: str,
) -> dict[str, object]:
response = await client.get(f"/inventory/{product_id}")
if response.status_code in {429, 502, 503, 504}:
raise RetryableHttpStatus(str(response.status_code))
response.raise_for_status()
return response.json()
result = await (
async_operation("load_product_inventory", load_inventory)
.with_retry(
RetryPolicy(
max_attempts=3,
backoff=ExponentialBackoff(
base_delay_seconds=0.25,
max_delay_seconds=2.0,
jitter=True,
),
retry_on=(
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
RetryableHttpStatus,
),
)
)
.with_timeout(TimeoutPolicy(seconds=10))
.run(client, "product-42")
)
print(result.value)
print(result.report.to_dict())
RelPrim is transport-agnostic. The same retry, timeout, reports, and structured events can wrap an HTTPX call, provider SDK, queue operation, or another async boundary.
The current release uses generic exponential backoff here. It does not yet
derive delay from an HTTP Retry-After response automatically, so keep
response-aware rate-limit handling in the HTTP adapter when that contract
matters.
See the RelPrim advanced usage guide for the current builder API and structured execution model.
Test the sequence, not only the exception
MockTransport can verify retry behavior without a real downstream service.
import httpx
import pytest
from http_retry import RetryPolicy, request_with_retry
@pytest.mark.asyncio
async def test_retries_503_and_reuses_the_client() -> None:
attempts = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts == 1:
return httpx.Response(
503,
headers={"Retry-After": "0"},
request=request,
)
return httpx.Response(
200,
json={"status": "ok"},
request=request,
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler)
) as client:
response = await request_with_retry(
client,
"GET",
"https://inventory.example/items",
policy=RetryPolicy(max_attempts=2),
)
assert response.json() == {"status": "ok"}
assert attempts == 2
Also test a non-idempotent POST without a key, a POST with one stable key,
an exhausted elapsed-time budget, a long Retry-After, cancellation during
backoff, and a validation failure that must not retry.
When retry makes the failure worse
Do not retry authentication failures, invalid requests, unsupported operations, or response validation errors without an explicit recovery strategy.
Avoid retrying when the dependency is already overloaded, when the remaining deadline cannot accommodate another attempt, or when the request body cannot be replayed. A circuit breaker, queue, fallback, reconciliation process, or direct failure may be the safer response.
Before you ship
Before enabling HTTPX retry, identify the owner of the complete operation budget. List every layer that can repeat the request and calculate the real maximum number of downstream attempts.
Classify failures by phase and outcome. Connection failure, response status,
read timeout, and invalid response data should not enter one generic exception
handler. Confirm that the HTTP method or idempotency contract makes replay safe,
honor Retry-After, reuse the client, and preserve cancellation.
Instrument each attempt with the operation name, method, dependency, attempt number, status or exception type, chosen delay, elapsed time, and final outcome. The metric that matters is not how often an attempt failed, but whether the logical operation recovered, failed, or remained uncertain.
Retries spend downstream capacity. Use them only when another attempt has a credible chance of improving the outcome.
Which failure does your HTTPX policy currently retry most aggressively: connection errors, timeouts, or 429 and 5xx responses?
