← Back to Home

Retries That Never Fire: urllib3 2.8.0 and tenacity 9.2.1

PythonrequestsRetry

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

ComponentVersionSource
Python3.13.14local sys.version
requests2.34.2PyPI upload 2026-05-14
urllib32.8.0PyPI upload 2026-09-15
tenacity9.2.1PyPI 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 responseServer countElapsedOutcome
503 with Retry-After: 143.009 sretried 3 times, 1 s each per the header, then returned 503
503, no Retry-After10.003 sreturned after a single try
500 with Retry-After: 110.002 sreturned 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:

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):

CombinationAt attempt 3 (0.3 s elapsed)At attempt 5 (0.5 s elapsed)
pipe, OR (stop_any)True, stopTrue, stop
ampersand, AND (stop_all)False, keep goingTrue, 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'))`

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'))`

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)`

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[]`

@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`

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

@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

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:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home