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.
Engineering Notes
Finding this useful?
Get the next one, along with occasional project updates and engineering notes.
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.
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:
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:
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?
