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.

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.

http_retry.py
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.

inventory_client.py
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.

test_http_retry.py
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?