Fixing Python requests Sessions That Silently Ignore Retry Logic
You configured a Retry object, mounted it on a Session, and your code still blows up on the first failed request. No retries, no backoff β just an immediate exception. This is one of the most common silent failures in Python networking code, and the cause is almost never obvious from the error message.
The good news: once you understand the three places where retry configuration can quietly break, you can fix it in under ten minutes.
What you'll learn
- Why
Retryadapters often appear to work but do nothing - The correct way to mount an adapter so it actually intercepts requests
- Which HTTP status codes and methods need explicit opt-in
- How to add exponential backoff without a third-party library
- How to verify retry behavior without hammering a real server
Prerequisites
You should have requests installed (pip install requests). The urllib3 library comes bundled with it, so no extra installs are needed. The examples below assume Python 3.8 or later, but the retry API has been stable for several major versions.
How retry adapters are supposed to work
The requests library delegates the actual HTTP transport to urllib3. When you create a Session, it registers two default HTTPAdapter instances β one for http:// and one for https://. Each adapter wraps a urllib3 connection pool, and that pool accepts a Retry object that controls retry behavior.
To override the defaults, you create a custom HTTPAdapter with your Retry object and mount it on the session. Every request whose URL prefix matches the mount point goes through your adapter.
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session = requests.Session()
session.mount("https://", adapter)
session.mount("http://", adapter)
response = session.get("https://example.com/api/data")
That's the textbook example. It looks right. And yet, for many developers, it still doesn't retry. Here's why.
Mistake 1: Mounting only one prefix
The most common oversight is mounting the adapter on https:// but forgetting http://, or vice versa. If your target URL uses the unmounted scheme, the request flows through the original default adapter β which has no retry logic at all.
Always mount on both prefixes unless you are 100% certain every URL in your codebase uses the same scheme. It costs nothing and prevents a confusing class of bugs where retries work in development (often HTTP) but fail silently in production (HTTPS, or the reverse).
# Do both, always
session.mount("https://", adapter)
session.mount("http://", adapter)
Mistake 2: The status code list alone is not enough
Setting status_forcelist tells urllib3 which HTTP status codes should trigger a retry. But there's a second gate: the HTTP method must also be in the retry-allowed list.
By default, urllib3 only retries idempotent methods β GET, HEAD, DELETE, PUT, OPTIONS, and TRACE. If you're calling a POST endpoint and getting a 503, the retry silently won't fire because POST is not in the default allowed set.
Add the allowed_methods parameter (previously called method_whitelist in older urllib3 versions) to include the methods you actually use:
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
)
Be deliberate about including
POST. Retrying a POST that creates a resource can cause duplicate records if the server processed the first request but returned a transient error on the response. Make sure your endpoint is idempotent or uses deduplication keys before adding POST to this list.
Mistake 3: Confusing connection errors with HTTP errors
The Retry object handles two distinct failure categories, and they're controlled separately.
Connection errors β the server is unreachable, DNS fails, the TCP handshake times out. These are retried via the total count and, more specifically, the connect parameter.
HTTP errors β the server responded, but with a bad status code. These are only retried when you provide status_forcelist. Without it, a 500 response is handed back to you as a normal response object; no retry happens.
retry_strategy = Retry(
total=5, # overall retry budget
connect=3, # max retries for connection failures
read=3, # max retries for read timeouts
status=3, # max retries for bad status codes
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
)
Using the granular parameters gives you finer control. A server that returns a lot of 429 Too Many Requests errors might warrant more status retries than connection retries, for example.
How backoff_factor actually works
The backoff_factor parameter controls exponential backoff between retries. The sleep time between attempt N and attempt N+1 follows this formula:
{backoff_factor} * (2 ** (retry_number - 1))
With backoff_factor=1, the delays are: 0 seconds (first retry), 2 seconds, 4 seconds, 8 seconds. The first retry has no delay because urllib3 skips the sleep for the very first attempt.
With backoff_factor=0.5, the delays are: 0, 1, 2, 4 seconds. For most APIs, a factor between 0.3 and 1.0 is a reasonable starting point. If you're hitting a rate-limited endpoint, respect the Retry-After header instead of relying purely on backoff math β but that requires a bit of custom middleware, shown below.
Respecting the Retry-After header
When a server returns a 429 with a Retry-After header, blindly retrying after a fixed backoff can get you blocked again immediately. The cleaner approach is to read the header and sleep for the specified duration before the next attempt.
urllib3's Retry class has a respect_retry_after_header parameter that does exactly this. It defaults to True, so as long as you're not explicitly setting it to False, urllib3 will already honor Retry-After for 413 and 429 responses:
retry_strategy = Retry(
total=5,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
respect_retry_after_header=True, # this is the default, shown for clarity
)
For other status codes with a Retry-After header, you'd need to handle that in application code after the session gives up. A thin wrapper that catches the exception, reads the response, and sleeps is usually the simplest approach.
Common pitfalls
raise_on_status hides retried errors
If you call response.raise_for_status() after a successful final retry, it works fine. But if the adapter exhausts all retries, it raises a MaxRetryError from urllib3, which gets wrapped in a requests.exceptions.RetryError. Make sure you're catching the right exception type:
from requests.exceptions import RetryError, ConnectionError
try:
response = session.get("https://example.com/api/data", timeout=10)
response.raise_for_status()
except RetryError as e:
print(f"All retries exhausted: {e}")
except ConnectionError as e:
print(f"Could not connect: {e}")
Session-level timeouts are separate from retries
A retry strategy does not impose a timeout on individual requests. Without a timeout argument, a single attempt can hang indefinitely, which means your retry logic never gets a chance to fire. Always set a timeout:
response = session.get(url, timeout=(3.05, 27))
# (3.05, 27) = connect timeout, read timeout in seconds
The connect timeout is how long to wait for the TCP handshake. The read timeout is how long to wait for data after the connection is open. If you pass a single number, it applies to both.
Creating a new session per request
If you instantiate requests.Session() inside a loop or inside the function that makes the request, you lose connection pooling and your retry adapter is rebuilt on every call. Create the session once, at module or application startup, and reuse it throughout the lifecycle of your program.
Testing retry behavior locally
Don't rely on a flaky real server to verify that your retries work. A minimal test server that returns a configurable status code on the first N requests is easy to build with Flask or http.server. For unit tests, the responses library lets you mock HTTP calls:
import responses as responses_lib
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def make_session():
s = requests.Session()
retry = Retry(total=3, status_forcelist=[500], backoff_factor=0)
adapter = HTTPAdapter(max_retries=retry)
s.mount("https://", adapter)
s.mount("http://", adapter)
return s
@responses_lib.activate
def test_retries_on_500():
# First two calls return 500, third returns 200
responses_lib.add(responses_lib.GET, "https://api.example.com/data", status=500)
responses_lib.add(responses_lib.GET, "https://api.example.com/data", status=500)
responses_lib.add(responses_lib.GET, "https://api.example.com/data", json={"ok": True}, status=200)
session = make_session()
resp = session.get("https://api.example.com/data")
assert resp.status_code == 200
assert len(responses_lib.calls) == 3
Set backoff_factor=0 in tests so they run fast. Verify by asserting on the number of calls β if retries are broken, you'll see only one call in responses.calls.
Wrapping up
Silent retry failures almost always come down to one of three root causes: the adapter mounted on the wrong URL prefix, POST or other non-idempotent methods excluded from the allowed list, or confusing HTTP status errors with connection errors. Here are the concrete next steps to take right now:
- Audit your mount calls. Confirm you have
session.mount("https://", adapter)andsession.mount("http://", adapter)β both, not one. - Check your allowed_methods list. Add any HTTP methods you're actually using. Be cautious with POST if the endpoint isn't idempotent.
- Add explicit timeouts to every request. A hanging connection will block your retry logic from ever kicking in.
- Write a unit test using the
responseslibrary that asserts on the number of HTTP calls made. This catches regressions before they reach production. - Log retries. Subclass
HTTPAdapterand overridesend()to emit a log line each time a retry fires β visibility into retry storms can save you hours of debugging.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!