Configure a retry policy, not just a number

A safe Python Requests retry setup needs more than HTTPAdapter(max_retries=3).

The policy should decide which failures are temporary, whether the HTTP operation can be repeated safely, how long to wait between attempts, when to honor Retry-After, and what the caller receives after the final attempt.

A conservative starting point is to retry connection failures and selected status codes for read-only methods:

from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry


retry = Retry(
    total=3,
    connect=3,
    read=0,
    status=3,
    other=0,
    allowed_methods=frozenset({"GET", "HEAD"}),
    status_forcelist=frozenset({429, 502, 503, 504}),
    backoff_factor=0.25,
    backoff_jitter=0.2,
    respect_retry_after_header=True,
    raise_on_status=False,
)

session = Session()
session.mount("https://", HTTPAdapter(max_retries=retry))

This does not make every failure retryable. It deliberately avoids automatic read retries and excludes POST until the operation has an idempotency contract.

The native adapter is the right place to begin because it matches the Requests transport model. Later in this guide, the same concerns are moved into RelPrim when retry becomes part of a wider operation policy rather than one HTTP adapter.

The APIs in this guide were checked against Requests 2.34.2 and urllib3 2.7.0 documentation on August 20, 2026.

Requests delegates retry to urllib3

requests.Session provides connection pooling and shared configuration. HTTPAdapter connects Requests to urllib3, and the detailed retry behavior comes from urllib3.util.Retry.

Passing an integer to max_retries mainly configures connection retries. Passing a Retry object exposes separate counters and policies.

Setting What it controls
total Overall retry cap
connect Failures before the request reaches the server
read Failures after the request may have been sent
status Responses listed in status_forcelist
other Errors outside the known categories
allowed_methods HTTP methods eligible for status-based retry

total takes precedence as the overall limit, while the category counters restrict individual failure types.

Setting other=0 is a useful defensive choice. Unexpected error categories may occur after the request was sent, so retrying them automatically can hide an ambiguous side effect.

Connection, read and status failures are not equivalent

A connection timeout occurs before a usable connection is established. Requests documents ConnectTimeout as safe to retry.

A read failure happens later. The server may already have received and processed the request. That makes automatic replay unsafe for a mutating operation unless the operation is idempotent.

Status retries are different again. The server returned a response, so the policy can inspect the status and any provider guidance.

A reasonable default classification is:

Failure Typical action
DNS or connection failure Bounded retry
Connect timeout Bounded retry
Read timeout Retry only when replay is safe
429 Respect Retry-After and the operation budget
502, 503, 504 Retry cautiously for eligible methods
Other 4xx Usually return immediately
Invalid response body Treat as an application policy decision

There is an important Requests-specific limitation. urllib3 has a read retry counter, but Requests does not preload the response body inside the adapter. Failures raised while the body is consumed later may therefore escape the adapter retry loop.

For the underlying timeout model, see What Python Requests Timeouts Mean for Connect, Read and Failure Modes.

Status retries need a deliberate final response

status_forcelist identifies status codes that can trigger another attempt when the method is also allowed.

Do not treat every 5xx as equivalent. A 503 from a documented maintenance window may be retryable. A repeated 500 caused by deterministic input will not improve through repetition.

With raise_on_status=False, urllib3 returns the final response after status retries are exhausted:

response = session.get(
    "https://inventory.example/items",
    timeout=(3.05, 10.0),
)

response.raise_for_status()

This keeps retry execution in the transport while the application decides how to translate the final HTTP response. With raise_on_status=True, exhaustion raises through the adapter instead. Pick one contract and keep it consistent.

Backoff and Retry-After solve different problems

backoff_factor calculates increasing local delays. urllib3 2.7.0 can also add a bounded random amount through backoff_jitter.

That jitter is added to the calculated backoff. It is not the same as full jitter across the entire exponential window.

Retry-After is a server hint. With respect_retry_after_header=True, urllib3 honors it for the supported rate-limit and availability statuses, including 429 and 503.

The provider hint should usually win over local backoff, but not over the caller’s complete deadline. Waiting 60 seconds is not useful when the user-facing operation has 8 seconds left.

Making Python Exponential Backoff Safe with Jitter, Retry Budgets and Production Trade-offs compares jitter strategies and explains why backoff still needs an elapsed-time budget.

A safe POST needs one stable identity

POST is excluded from urllib3’s default allowed methods because repeating it may create another effect.

When the server supports idempotency keys, one dedicated client can allow retry for a specific integration:

payment_retry = retry.new(
    allowed_methods=frozenset({"POST"}),
    status_forcelist=frozenset({429, 503}),
    read=0,
)

session.mount(
    "https://payments.example/",
    HTTPAdapter(max_retries=payment_retry),
)

response = session.post(
    "https://payments.example/payments",
    headers={"Idempotency-Key": operation_id},
    json=payload,
    timeout=(3.05, 10.0),
)

The same operation_id must survive every retry. Generating a new UUID inside the retry path creates a new business operation.

The provider must enforce the key, reject payload mismatches, coordinate duplicates, and replay the original result. A header alone is not a guarantee.

See Building Idempotency in REST APIs with Keys, Concurrency and Safe Retries for the server-side protocol.

A read timeout can still leave the result unknown. Retry only when the idempotency and recovery contract covers that ambiguity.

Attempt limits are not a total deadline

Retry(total=3) limits retry count. It does not create one elapsed-time budget for connection setup, request timeouts, backoff sleeps, redirects, and caller work.

A tuple timeout such as:

timeout=(3.05, 10.0)

also applies to one transport attempt, not the complete operation.

For a strict business deadline, enforce the budget above the adapter and pass the remaining time into each attempt.

Avoid stacking retry layers. An SDK with three attempts inside an application loop with three attempts can cross the provider boundary nine times.

The same retry multiplication problem appears in OpenAI Timeouts in Python for Retries, Streaming and Long-Running Responses.

Reuse the Session and observe the real sequence

Reuse one Session for the integration scope to preserve connection pooling and avoid fresh TCP and TLS setup on every attempt.

The final urllib3 retry object is available through the raw response:

history = response.raw.retries.history

for attempt in history:
    logger.info(
        "http_retry",
        extra={
            "method": attempt.method,
            "status": attempt.status,
            "error": type(attempt.error).__name__
            if attempt.error
            else None,
        },
    )

Also record:

  • dependency and operation name;
  • final status or exception type;
  • selected delay and Retry-After;
  • elapsed wall time;
  • whether the final outcome recovered, failed, or remained unknown;
  • idempotency key hash for mutating operations.

Attempt metrics explain transport behavior. Operation metrics explain the user-visible outcome.

Test retries with a controlled server

A local server can verify the real adapter behavior without depending on a public endpoint.

test_requests_retry.py
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry


class RecoveringHandler(BaseHTTPRequestHandler):
    attempts = 0

    def do_GET(self) -> None:
        type(self).attempts += 1

        if type(self).attempts < 3:
            self.send_response(503)
            self.send_header("Retry-After", "0")
            self.end_headers()
            return

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"status":"ok"}')

    def log_message(self, *_: object) -> None:
        pass


def test_retries_503_until_the_server_recovers() -> None:
    server = ThreadingHTTPServer(
        ("127.0.0.1", 0),
        RecoveringHandler,
    )
    thread = threading.Thread(
        target=server.serve_forever,
        daemon=True,
    )
    thread.start()

    retry = Retry(
        total=2,
        status=2,
        connect=0,
        read=0,
        other=0,
        allowed_methods=frozenset({"GET"}),
        status_forcelist=frozenset({503}),
        respect_retry_after_header=True,
        raise_on_status=False,
    )

    try:
        with requests.Session() as session:
            session.mount(
                "http://",
                HTTPAdapter(max_retries=retry),
            )
            response = session.get(
                f"http://127.0.0.1:{server.server_port}/",
                timeout=(1.0, 1.0),
            )

        assert response.json() == {"status": "ok"}
        assert RecoveringHandler.attempts == 3
        assert len(response.raw.retries.history) == 2
    finally:
        server.shutdown()
        server.server_close()

The test was executed with Python 3.13.5, Requests 2.32.5, and urllib3 2.7.0. The documented configuration was also checked against Requests 2.34.2.

Also test an exhausted status policy, Retry-After, a non-retried POST, one stable idempotency key, and a response-body failure outside the adapter loop.

Moving the operation policy into RelPrim

The Requests adapter is a good fit for narrow transport retry. When the same operation also needs timeout composition, validation, fallback, circuit breaking, idempotency, events, and an execution report, RelPrim can own that policy above the transport.

Its low-level RetryPolicy supports synchronous callables, so it can be used directly with a shared Requests Session:

relprim_requests.py
from __future__ import annotations

import requests
from relprim import ExponentialBackoff, RetryPolicy


class RetryableInventoryResponse(RuntimeError):
    pass


def load_inventory(
    session: requests.Session,
    product_id: str,
) -> dict[str, object]:
    response = session.get(
        f"https://inventory.example/products/{product_id}",
        timeout=(3.05, 10.0),
    )

    if response.status_code in {429, 502, 503, 504}:
        raise RetryableInventoryResponse(
            f"Temporary inventory response: {response.status_code}"
        )

    response.raise_for_status()
    payload = response.json()

    if "available" not in payload:
        raise ValueError("Inventory response is missing 'available'")

    return payload


retry_policy = RetryPolicy(
    max_attempts=3,
    retry_on=(
        requests.ConnectTimeout,
        requests.ConnectionError,
        RetryableInventoryResponse,
    ),
    backoff=ExponentialBackoff(
        base_delay_seconds=0.25,
        multiplier=2.0,
        max_delay_seconds=3.0,
        jitter=True,
    ),
)


with requests.Session() as session:
    inventory = retry_policy.run(
        load_inventory,
        session,
        "product-42",
    )

The example deliberately excludes ReadTimeout. Replaying this GET may be acceptable, but that should be explicit rather than hidden behind a broad requests.Timeout catch.

The full RelPrim operation builder goes further for async callables:

relprim_operation.py
from relprim import (
    EventEmitter,
    ExponentialBackoff,
    InMemoryEventSink,
    RetryPolicy,
    TimeoutPolicy,
    async_operation,
    fallback_chain,
    validation_policy,
    validator,
)


event_sink = InMemoryEventSink()
events = EventEmitter(sinks=(event_sink,))

valid_inventory = validation_policy(
    validator(
        "inventory_shape",
        lambda value: "available" in value,
        message="Inventory response is missing 'available'.",
    )
)

result = await (
    async_operation("load_product_inventory", load_inventory_async)
    .with_retry(
        RetryPolicy(
            max_attempts=3,
            retry_on=(TemporaryInventoryError,),
            backoff=ExponentialBackoff(
                base_delay_seconds=0.25,
                max_delay_seconds=3.0,
                jitter=True,
            ),
        )
    )
    .with_timeout(TimeoutPolicy(seconds=12))
    .with_validation(valid_inventory)
    .with_fallbacks(
        fallback_chain(
            ("cached_inventory", load_cached_inventory),
        )
    )
    .with_events(events)
    .run("product-42")
)

print(result.value)
print(result.report.to_dict())

The returned OperationResult keeps the value and execution report together, including attempts, validation, fallback use, durations, and final status.

For mutating operations, RelPrim can coordinate the lifecycle under one idempotency key. Its default store is in-memory and single-process, so the API that owns the effect still needs durable server-side protection where required.

RelPrim 0.10.0 does not derive delay from HTTP Retry-After automatically. Keep response-aware waiting in the integration without creating a second retry loop.

See the RelPrim advanced usage guide and RelPrim idempotency guide for the current APIs and storage boundaries.

Before you ship

Before enabling Requests retry, define which layer owns the complete operation policy. Count every place where a request can repeat, including adapters, SDKs, workers, and application loops.

Retry connection failures and selected statuses only when another attempt can realistically improve the outcome. Keep methods conservative, require an idempotency contract for mutating operations, and preserve an unknown state when a timeout may have followed a successful side effect.

Set explicit connect and read timeouts, reuse the Session, honor Retry-After within the remaining deadline, and inspect the final response deliberately. When the policy grows beyond transport retry, move it into one operation-level abstraction such as RelPrim rather than adding another nested loop.

Measure the full operation outcome rather than celebrating a transport retry that merely delayed failure.

A retry policy should reduce transient failure without turning uncertainty into duplicate work or downstream overload.

Which Requests retry is hardest to classify in your system: read timeouts, provider 5xx responses, or a timed-out POST with an uncertain outcome?