Retries That Never Fire: urllib3 2.8.0 and tenacity 9.2.1
TL;DR: Every number in this article was measured on my own machine. I stood up an HTTP server on 127.0.0.1 that counts how many requests it receives, then changed one setting at a time and read the counter. The short version: plain requests never retries, even once. Configuring Retry(3) does not mean it will retry. Even when it does retry, backoff_factor defaults to 0, so four requests go out within a few milliseconds. And tenacity, one layer up, silently replaces your original exception with RetryError, which means except ValueError never fires. Configure both layers and the retry counts multiply.
Environment and how to reproduce it
| Component | Version | Source |
|---|---|---|
| Python | 3.13.14 | local sys.version |
| requests | 2.34.2 | PyPI upload 2026-05-14 |
| urllib3 | 2.8.0 | PyPI upload 2026-09-15 |
| tenacity | 9.2.1 | PyPI upload 2026-10-07 |
Pin them first, so "works on my version" is not a variable:
python -m pip install "requests==2.34.2" "urllib3==2.8.0" "tenacity==9.2.1"
python - <<'PY'
import sys, requests, urllib3, importlib.metadata as md
print(sys.version.split()[0], requests.__version__, urllib3.__version__, md.version('tenacity'))
PY
Every "the server saw N requests" figure below comes from this minimal server. It increments a counter on each business request and exposes /__count so you can read it at any time.
import json, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {'count': 0, 'status': 503, 'retry_after': None}
class H(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *a): pass
def _send(self, code, body=b'{}'):
self.send_response(code)
if STATE['retry_after'] is not None:
self.send_header('Retry-After', str(STATE['retry_after']))
self.send_header('Content-Length', str(len(body)))
self.end_headers(); self.wfile.write(body)
def do_GET(self):
if self.path == '/__count':
self._send(200, json.dumps({'count': STATE['count']}).encode()); return
STATE['count'] += 1
self._send(STATE['status'])
do_POST = do_GET
class Quiet(ThreadingHTTPServer):
def handle_error(self, *a): pass # hang-up scenarios spam tracebacks
srv = Quiet(('127.0.0.1', 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
print('http://127.0.0.1:%d' % srv.server_address[1])
Layer 1: urllib3 2.8.0 — four switches, four different jobs
Almost every "I configured retries and nothing happened" report comes from this layer. Retry is not a single "try three times" knob. It is four independent conditions, and failing any one of them means no retry at all.
Switch 1: the method allowlist — POST is never retried by default
The default allowlist is right there in the source:
from urllib3.util.retry import Retry
print(Retry.DEFAULT_ALLOWED_METHODS)
# frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'})
The official urllib3.util.Retry reference says of allowed_methods: "By default, we only retry on methods which are considered to be idempotent." POST is not in that set.
Measured, same Retry object, only the HTTP method changes:
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter
import requests
def sess(retry):
s = requests.Session()
s.mount('http://', HTTPAdapter(max_retries=retry))
return s
r = Retry(total=3, status_forcelist=[503], backoff_factor=0)
sess(r).get(BASE + '/api') # server counter = 4, raises RetryError
sess(r).post(BASE + '/api') # server counter = 1, quietly returns
One line of configuration: GET goes out 4 times, POST goes out once, and the POST call raises nothing. If you assumed "I configured retries, so my write endpoint is safe," this is the hole. The write went out, the server answered 503, and your code received what looks like a perfectly normal Response.
To open it up, declare allowed_methods=None explicitly (the docs say "Set to a None value to retry on any verb"). Measured: POST then also goes out 4 times. But that pulls non-idempotent requests into retry territory, so confirm the endpoint really is idempotent first.
Switch 2: status_forcelist — without it, only 429/503/413 with a Retry-After header
Retry.is_retry() has exactly two paths:
def is_retry(self, method, status_code, has_retry_after=False):
if not self._is_method_retryable(method):
return False
if self.status_forcelist and status_code in self.status_forcelist:
return True
return bool(self.total and self.respect_retry_after_header
and has_retry_after
and (status_code in self.RETRY_AFTER_STATUS_CODES))
And Retry.RETRY_AFTER_STATUS_CODES = frozenset({413, 429, 503}). So "not setting status_forcelist" really means: only 429, 503 and 413 get retried, and only when the response carries a Retry-After header.
Three runs, identical config (Retry(total=3, backoff_factor=0)), only the server response changes:
| Server response | Server count | Elapsed | Outcome |
|---|---|---|---|
503 with Retry-After: 1 | 4 | 3.009 s | retried 3 times, 1 s each per the header, then returned 503 |
503, no Retry-After | 1 | 0.003 s | returned after a single try |
500 with Retry-After: 1 | 1 | 0.002 s | returned after a single try (500 is not one of the three) |
Rows two and three are the ones that bite. 500, 502 and 504 — the most common server-side failures — are not retried under the default configuration, no matter how loudly your logs insist you set total=3.
Switch 3: backoff_factor defaults to 0 — retrying with zero backoff
Retry.__init__ declares backoff_factor: float = 0, and the docs are blunt about it: "By default, backoff is disabled (factor set to 0)."
The formula lives in get_backoff_time(), and note the short-circuit that skips the first two waits:
if consecutive_errors_len <= 1:
return 0
backoff_value = self.backoff_factor * (2 ** (consecutive_errors_len - 1))
if self.backoff_jitter != 0.0:
backoff_value += random.random() * self.backoff_jitter
return float(max(0, min(self.backoff_max, backoff_value)))
Measured with backoff_factor=0.1, the wait before retries 1 through 4:
r = Retry(total=10, status_forcelist=[503], backoff_factor=0.1)
for i in range(1, 5):
r = r.increment(method='GET', url='/api')
print(round(r.get_backoff_time(), 4))
# 0 0.2 0.4 0.8 <- matches the docs' example [0.0s, 0.2s, 0.4s, 0.8s]
Set the factor to 0, the default, and all four numbers are 0. A real run at backoff_factor=0.2: 4 requests, 1.208 s wall clock (0 + 0.4 + 0.8). At the default it is 0.005 s for all four.
backoff_max defaults to 120 seconds and clips anything above it. Measured with backoff_factor=10: [0, 0, 20, 40, 80, 120, 120, 120, ...].
Switch 4: requests' default adapter is `Retry(0, read=False)`
This is the cut furthest upstream. From requests/adapters.py:
if max_retries == DEFAULT_RETRIES:
self.max_retries = Retry(0, read=False)
else:
self.max_retries = Retry.from_int(max_retries)
So unless you pass max_retries explicitly, what you get is Retry(0, read=False). The official requests API documentation says the same thing in prose: "By default, Requests does not retry failed connections."
Two measurements:
- Server accepts the connection and hangs up immediately (a read error): the default session connects once and raises
ConnectionError. WithRetry(total=3)it connects 4 times. - Top-level
requests.get(...)does not use an adapter you mounted on some other session — it builds a fresh session every call. Measured on the same URL: the session version fires 4 requests and raisesRetryError;requests.getfires 1 and quietly returns 503.
s = requests.Session()
s.mount('http://', HTTPAdapter(max_retries=Retry(total=3, status_forcelist=[503])))
s.get(BASE + '/api') # 4 requests, RetryError
requests.get(BASE + '/api') # 1 request, returns 503 (top-level API uses the default adapter)
Layer 2: tenacity 9.2.1 — three defaults that change your meaning
reraise=False: your `except ValueError` can never fire
The official tenacity docs put it like this: "Normally when your function fails its final time ... a RetryError is raised. The exception your code encountered will be shown somewhere in the middle of the stack trace." The default is reraise=False.
Measured:
import tenacity as T
@T.retry(stop=T.stop_after_attempt(3), wait=T.wait_fixed(0))
def boom():
raise ValueError('the original error')
try:
boom()
except ValueError:
print('caught it') # never runs
except Exception as e:
print(type(e).__name__) # RetryError
print(str(e)[:80])
# RetryError[]
Three calls, as configured. But what propagates is RetryError, and isinstance(e, ValueError) is False. Add reraise=True and ValueError comes out intact.
What makes this one expensive is that it never raises a flag. The retry logic looks like it works; your error-handling branch just becomes dead code.
A bare @retry: infinite retries with zero wait
import tenacity as T
r = T.Retrying()
print(r.stop, r.wait, r.retry)
#
The docs describe a bare @retry as: "The default behavior is to retry forever without waiting when an exception is raised."
I ran it on a background thread for 3 seconds and counted 115,689 calls, at zero interval. That is not a retry policy; it is a self-inflicted load test against your own downstream. Every bare @retry needs at least a stop=.
wait_exponential's default max is effectively no max at all
wait_exponential.__init__ is (multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0), and tenacity._utils.MAX_WAIT = 4.611686018427388e+18 seconds.
w = T.wait_exponential(multiplier=1)
print([round(w(St(i)), 3) for i in range(1, 21)])
# [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192,
# 16384, 32768, 65536, 131072, 262144, 524288]
Retry number 20 waits 524288 seconds, which is 6.07 days. Add max=10 and it becomes [1, 2, 4, 8, 10.0, 10.0, 10.0].
Its own docstring draws the boundary too: "The intervals are fixed (i.e. there is no jitter), so this strategy is suitable for balancing retries against latency ... but *not* suitable for resolving contention between multiple processes for a shared resource. Use wait_random_exponential for the latter case." In other words, plain exponential backoff synchronises every client onto the same retry instants. You need wait_random_exponential to get jitter.
The pipe operator is OR, the ampersand is AND
In the source, stop_base.__or__ returns stop_any and __and__ returns stop_all:
def __or__(self, other): return stop_any(self, other) # any(...)
def __and__(self, other): return stop_all(self, other) # all(...)
The docs' own example, stop_after_delay(10) | stop_after_attempt(5), is annotated "Stopping after 10 seconds or 5 retries" — whichever comes first. Measured with stop_after_attempt(5) and stop_after_delay(0.3):
| Combination | At attempt 3 (0.3 s elapsed) | At attempt 5 (0.5 s elapsed) |
|---|---|---|
pipe, OR (stop_any) | True, stop | True, stop |
ampersand, AND (stop_all) | False, keep going | True, stop |
Think twice before writing the ampersand: it means both conditions must hold before stopping, so the real attempt count can run well past the number you wrote.
Stacking both layers: retry counts multiply
Wrap tenacity around requests, stop=stop_after_attempt(3) outside, Retry(total=3, status_forcelist=[503], backoff_factor=0) inside:
sess = requests.Session()
sess.mount('http://', HTTPAdapter(max_retries=Retry(total=3, status_forcelist=[503], backoff_factor=0)))
@T.retry(stop=T.stop_after_attempt(3), wait=T.wait_fixed(0), reraise=True)
def nested():
return sess.get(BASE + '/api', timeout=2)
Measured: tenacity calls the function 3 times; the server receives 12 HTTP requests. 3 x (1 + 3) = 12.
Both numbers in your code are 3. What actually goes out is 12. Multiply by N concurrent clients doing the same thing and you have the arithmetic behind a retry storm. Keep one retry layer per call path; delete the other or set it to 0 explicitly.
Troubleshooting: six real errors and their fixes
Error 1: `requests.exceptions.RetryError: ... Max retries exceeded with url: /api (Caused by ResponseError('too many 503 error responses'))`
- Symptom: after adding
status_forcelist=[503], four requests go out within milliseconds and thenRetryErroris raised. - Cause: this is retries working, not failing. The actual defect is the speed —
backoff_factordefaults to 0, so all four requests land in 0.005 s. - Fix: add backoff, and add an overall cap.
Retry(total=3, status_forcelist=[500, 502, 503, 504],
backoff_factor=0.5, backoff_max=8,
allowed_methods=frozenset(['GET', 'POST'])) # POST must be named explicitly
Error 2: `requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))`
- Symptom: the server hangs up on you and the client fails on the first try, with no retry at all.
- Cause: the default adapter is
Retry(0, read=False).read=Falsemakes read errors re-raise immediately; they never enter the retry path. - Fix: mount an adapter explicitly. Measured: switching to
Retry(total=3)took the connection count from 1 to 4.
s = requests.Session()
s.mount('http://', HTTPAdapter(max_retries=Retry(total=3)))
s.mount('https://', HTTPAdapter(max_retries=Retry(total=3)))
And the one-line check that tells you what you actually have:
print(requests.Session().get_adapter('https://x/').max_retries)
# Retry(total=0, connect=None, read=False, redirect=None, status=None)
Error 3: `requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)`
- Symptom:
r.json()blows up with a completely unrelated message and sends the investigation sideways. - Cause: the upstream returned an HTML 503 page. Under the default configuration a 503 is not retried, so requests hands it back as an ordinary response and
.json()is where it finally breaks. - Fix: do both — add 5xx to
status_forcelist, and check the status code in your own code.
r = sess.get(url, timeout=5)
if r.status_code >= 500: # do not wait for .json() to tell you
raise RuntimeError(f'upstream 5xx: {r.status_code}')
data = r.json()
Error 4: `tenacity.RetryError[]`
- Symptom: the function really did retry three times, but the
except ValueErrorbranch never runs and the log holds nothing but aRetryError. - Cause:
reraise=Falseby default. tenacity wraps the final attempt inRetryError— the source buildsretry_exc = self.retry_error_cls(fut)and only callsretry_exc.reraise()whenself.reraiseis true. - Fix:
reraise=True. Measured: with it,ValueErrorpropagates as itself.
@T.retry(stop=T.stop_after_attempt(3), wait=T.wait_fixed(0), reraise=True)
def boom(): ...
Error 5: `requests.exceptions.InvalidHeader: Invalid Retry-After header: soon-ish`
- Symptom: upstream sends a non-standard
Retry-After(neither integer seconds nor a valid HTTP date) and the call raisesInvalidHeaderwithout a single retry. - Cause:
Retry.parse_retry_after()first matches^\s[0-9]+\s$; if that fails it hands the value toemail.utils.parsedate_tz, and if that fails too it doesraise InvalidHeader. That is correct per spec — but it knocks out the retry you were counting on. - Fix: stop deferring to
Retry-Afterand use your own backoff.
Retry(total=3, status_forcelist=[429, 503], respect_retry_after_header=False,
backoff_factor=0.5, backoff_max=8)
Worth knowing: Retry-After is capped. DEFAULT_RETRY_AFTER_MAX = 21600 (six hours), and the source does if seconds > self.retry_after_max: seconds = self.retry_after_max. An absurd upstream value will not put you to sleep forever, but six hours is still six hours.
Error 6: the program just hangs — wait_exponential with no max
- Symptom: retry logic appears to freeze. CPU idle, logs stop.
- Cause:
wait_exponential(multiplier=1)defaultsmaxto 4.61e18 seconds, which is no cap at all. Measured: retry 20 waits 524288 seconds (6.07 days). - Fix: always pass
maxexplicitly, and addstop_after_delayas a backstop.
@T.retry(wait=T.wait_exponential(multiplier=1, max=10),
stop=(T.stop_after_attempt(5) | T.stop_after_delay(30)), # note: OR
reraise=True)
def call(): ...
A configuration you can copy
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import tenacity as T
# Transport layer: decides whether THIS connection or THIS response gets another go
RETRY = Retry(
total=3,
connect=3,
read=2,
status=3,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(['GET', 'POST']), # POST must be named explicitly
backoff_factor=0.5,
backoff_max=8,
backoff_jitter=0.3, # spread the retry instants
respect_retry_after_header=True,
raise_on_status=False, # return the response; let the caller judge
)
session = requests.Session()
session.mount('http://', HTTPAdapter(max_retries=RETRY, pool_maxsize=20))
session.mount('https://', HTTPAdapter(max_retries=RETRY, pool_maxsize=20))
# Application layer: decides whether the WHOLE operation runs again. Needs stop and max.
@T.retry(
wait=T.wait_random_exponential(multiplier=1, max=10), # exponential, with jitter
stop=(T.stop_after_attempt(3) | T.stop_after_delay(30)),
retry=T.retry_if_exception_type((requests.Timeout, requests.ConnectionError)),
reraise=True,
)
def fetch(url):
r = session.get(url, timeout=(3.0, 10.0))
if r.status_code >= 500:
raise requests.HTTPError(f'upstream 5xx: {r.status_code}', response=r)
return r.json()
The split is deliberate. The transport layer owns the single request; the application layer owns the whole operation, and it retries only transient failures — timeouts and connection errors. Business-level 5xx is raised so the caller can decide. Retry both layers indiscriminately, as I measured, and you get 12 requests.
A four-step checklist
1. Look at the Retry object you actually have: Session().get_adapter(url).max_retries. The default reads Retry(total=0, connect=None, read=False, ...), which means no retries.
2. Check whether the method is on the allowlist: POST is not there by default, so either name it in allowed_methods or set allowed_methods=None.
3. Check whether status_forcelist is set at all: without it, only 429/503/413 carrying a Retry-After header are retried.
4. Check whether backoff_factor is 0: if it is, you have retry with zero backoff, which amplifies load instead of spreading it.
FAQ
Q: I set max_retries=3 and the server still only saw one request. Why?
A: Three candidates, most likely first: the method is POST, which is not on the default allowlist; the status code is not in status_forcelist and there is no Retry-After header; or you called top-level requests.get() instead of a session with an adapter mounted. All three measured as "one request, no exception."
Q: Does total=3 mean 3 requests or 4?
A: 4. total counts retries, not attempts; the first try is not included. Measured server count: 4.
Q: Can I keep only one of the two retry layers?
A: You should. Keep the transport layer and you cover connections and status codes, but not "the whole operation failed." Keep the application layer and the semantics are explicit and controllable, though each retry rebuilds the request. Retry both indiscriminately and it is 3 x 4 = 12 requests, measured.
Q: When does stop_after_delay start counting?
A: From the moment the Retrying object calls begin(), and it includes the time spent waiting between retries. It shares one budget with wait — which is exactly why combining stop_after_attempt and stop_after_delay with a pipe makes sense.
Q: Do retries actually raise my success rate?
A: Only for transient failures. Against sustained 5xx, exhausted quota, or bad arguments, retries just multiply the same failure by N and hold your connection pool hostage. The test is simple: will this error still be there in 200 milliseconds?
Related reading
- Python logging, tested: lost records, duplicate output and 6 real errors — whether the log lines your retries emit actually reach disk is a separate failure surface.
- PEP 440 pinning: one digit after the tilde, and a false claim from pip — the same "the default is not what you assumed" thread, one layer down in dependency resolution.
- n8n Task Runner: external and queue modes, five real error root causes — timeouts and retries in another shape, inside a self-hosted service.
Every figure here was measured locally (Python 3.13.14 / requests 2.34.2 / urllib3 2.8.0 / tenacity 9.2.1) against a counting server on the loopback address. No third-party production endpoint was involved, and this page carries no affiliate links. Source excerpts come from the packages installed on this machine; the documentation positions come from the requests API reference, the urllib3.util.Retry reference page, and the official tenacity documentation, fetched 2026-10-24. Behaviour changes between versions, so re-run the counting script above in your own environment before relying on any of it.
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: