重试为什么没生效:requests 2.34.2 与 tenacity 9.2.1 的四处静默失效
TL;DR:这篇文章全部结论都来自本机实测——我在 127.0.0.1 上起了一个会数自己收到几次请求的 HTTP 服务,然后逐条改配置、逐条看服务端计数。结论是:默认 requests 一次都不重试;你配了 Retry(3) 也不等于会重试;就算真的重试了,backoff_factor 默认 0 意味着四次请求会在几毫秒内打完;而应用层的 tenacity 默认会把你的原始异常换成 RetryError,让 except ValueError 永远抓不到。两层都配时,重试次数是相乘的。
实测环境与可复现前提
| 组件 | 版本 | 出处 |
|---|---|---|
| Python | 3.13.14 | 本机 sys.version |
| requests | 2.34.2 | PyPI 上传于 2026-05-14 |
| urllib3 | 2.8.0 | PyPI 上传于 2026-09-15 |
| tenacity | 9.2.1 | PyPI 上传于 2026-10-07 |
先把环境钉死,避免「我这版本不一样」:
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
本文所有「服务端收到 N 次请求」都是真实计数,用的是下面这个最小服务端:它把每一次业务请求累加到一个计数器里,并提供 /__count 让你随时读。
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 # 掐断连接的场景会刷一堆 traceback,静音
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])
第一层:urllib3 2.8.0 —— 四个开关各管一件事
绝大多数「配了重试没效果」的问题都出在这一层。urllib3 的 Retry 不是一个「重试 3 次」的旋钮,而是四个互相独立的判据,任何一个不满足都不会重试。
开关一:方法白名单,POST 默认一次都不重试
Retry 的源码里写着默认白名单:
from urllib3.util.retry import Retry
print(Retry.DEFAULT_ALLOWED_METHODS)
# frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'})
官方文档 urllib3.util.Retry 条目对 allowed_methods 的说明是「By default, we only retry on methods which are considered to be idempotent」——默认只重试幂等方法。POST 不在里面。
实测对照(同一个 Retry 对象,只换 HTTP 方法):
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') # 服务端计数 = 4,抛 RetryError
sess(r).post(BASE + '/api') # 服务端计数 = 1,安静地返回
同一行配置,GET 打出去 4 次,POST 只打出去 1 次,而且 POST 那条不抛任何异常。如果你以为「我配了重试所以写接口是安全的」,这里就是坑:写请求发出去了、服务端返回了 503、你的代码拿到的是一个看起来正常的 Response 对象。
要放开就显式声明 allowed_methods=None(官方写的是「Set to a None value to retry on any verb」),实测加上它之后 POST 同样是 4 次。但注意这是把非幂等请求也纳入重试,先确认你的接口真的幂等。
开关二:status_forcelist,不写就只认 429/503/413 且必须带 Retry-After
Retry.is_retry() 的判定只有两条路径:
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))
而 Retry.RETRY_AFTER_STATUS_CODES = frozenset({413, 429, 503})。所以「不写 status_forcelist」的真实含义是:只有带 Retry-After 响应头的 429/503/413 才会被重试。
三组实测,配置完全一样(Retry(total=3, backoff_factor=0)),只改服务端响应:
| 服务端响应 | 服务端计数 | 耗时 | 结果 |
|---|---|---|---|
503 + Retry-After: 1 | 4 | 3.009 s | 重试 3 次,每次按响应头等 1 秒,最后返回 503 |
503,无 Retry-After | 1 | 0.003 s | 一次就返回 |
500 + Retry-After: 1 | 1 | 0.002 s | 一次就返回(500 不在那三个码里) |
第二行和第三行是最容易踩的:500、502、504 这类最常见的服务端错误,在默认配置下不会被重试,哪怕你在日志里看到「明明配了 total=3」。
开关三:backoff_factor 默认 0 —— 配了重试,但零退避
Retry.__init__ 里 backoff_factor: float = 0,官方文档写得很直白:「By default, backoff is disabled (factor set to 0)」。
退避公式在 get_backoff_time() 里,注意它有一个「前两次不睡」的短路:
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)))
实测 backoff_factor=0.1 时,第 1 到第 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 ← 与官方文档给的 [0.0s, 0.2s, 0.4s, 0.8s] 一致
把 factor 换成 0(默认值),这四位数全是 0。真实跑一次 backoff_factor=0.2 的版本:4 次请求,墙钟 1.208 秒(0 + 0.4 + 0.8);换成默认 0 就是 0.005 秒打完 4 次。
backoff_max 默认 120 秒,超了会被截断。实测 backoff_factor=10 的序列:[0, 0, 20, 40, 80, 120, 120, 120, ...]。
开关四:requests 的默认适配器是 `Retry(0, read=False)`
这是最上游的一刀。requests/adapters.py 的 HTTPAdapter.__init__ 里:
if max_retries == DEFAULT_RETRIES:
self.max_retries = Retry(0, read=False)
else:
self.max_retries = Retry.from_int(max_retries)
也就是说,只要你没显式传 max_retries,拿到的就是 Retry(0, read=False)。requests 官方 API 文档对 max_retries 的说明是同一口径:「By default, Requests does not retry failed connections.」
两个实测:
- 服务端接受连接后立刻掐断(制造读错误):默认 Session 只连了 1 次就抛
ConnectionError;换成Retry(total=3)后连了 4 次。 - 顶层
requests.get(...)不会用到你给某个 Session 挂的适配器——它每次新建一个 Session。实测同一个 URL:Session 版本打 4 次并抛RetryError,requests.get版本只打 1 次并安静返回 503。
s = requests.Session()
s.mount('http://', HTTPAdapter(max_retries=Retry(total=3, status_forcelist=[503])))
s.get(BASE + '/api') # 4 次,RetryError
requests.get(BASE + '/api') # 1 次,返回 503(顶层 API 走默认适配器)
第二层:tenacity 9.2.1 —— 三个默认值会改掉你的语义
reraise=False:你的 `except ValueError` 永远抓不到
tenacity 官方文档的原话是:「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.」默认 reraise=False。
实测:
import tenacity as T
@T.retry(stop=T.stop_after_attempt(3), wait=T.wait_fixed(0))
def boom():
raise ValueError('原始错误')
try:
boom()
except ValueError:
print('抓到了') # 不会执行
except Exception as e:
print(type(e).__name__) # RetryError
print(str(e)[:80])
# RetryError[]
调用次数是 3 次没错,但抛出来的是 RetryError,isinstance(e, ValueError) 是 False。加上 reraise=True 才会把 ValueError 原样抛出来。
这条最伤人的地方在于它不报错:重试逻辑「看起来在工作」,只是你的错误处理分支从此变成死代码。
裸 @retry:无限重试 + 零等待
import tenacity as T
r = T.Retrying()
print(r.stop, r.wait, r.retry)
#
官方文档对裸 @retry 的描述是:「The default behavior is to retry forever without waiting when an exception is raised.」
我把它跑在一个后台线程里 3 秒,实测调用次数是 115,689 次,而且是零间隔——这不是重试,这是对下游的一次自我压力测试。任何裸 @retry 都至少要补 stop=。
wait_exponential 的默认 max 等于没有上限
wait_exponential.__init__ 的签名是 (multiplier=1, max=_utils.MAX_WAIT, exp_base=2, min=0),而 tenacity._utils.MAX_WAIT = 4.611686018427388e+18 秒。
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]
第 20 次重试要等 524288 秒 = 6.07 天。加上 max=10 之后变成 [1, 2, 4, 8, 10.0, 10.0, 10.0]。
它自己的 docstring 也写明了适用边界:「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.」也就是说,纯指数退避会把所有客户端对齐到同一批时刻重试,要用 wait_random_exponential 才有抖动。
`|` 是 OR,`&` 是 AND
源码里 stop_base.__or__ 返回 stop_any,__and__ 返回 stop_all:
def __or__(self, other): return stop_any(self, other) # any(...)
def __and__(self, other): return stop_all(self, other) # all(...)
官方文档的例子 stop_after_delay(10) | stop_after_attempt(5) 注释是「Stopping after 10 seconds or 5 retries」——任一满足即停。实测 stop_after_attempt(5) 与 stop_after_delay(0.3):
| 组合 | 第 3 次(已耗时 0.3 s) | 第 5 次(已耗时 0.5 s) |
|---|---|---|
用竖线组合(OR / stop_any) | True(停) | True(停) |
用 and 符号组合(AND / stop_all) | False(继续) | True(停) |
写 & 的时候想清楚:那意味着两个条件都满足才停,实际重试次数可能远超你写的那个数字。
两层叠加:重试次数是相乘的
把 tenacity 包在 requests 外面,stop=stop_after_attempt(3),内层 Retry(total=3, status_forcelist=[503], backoff_factor=0):
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)
实测结果:tenacity 调用 3 次,服务端收到 12 次 HTTP 请求。3 x (1 + 3) = 12。
你写在代码里的两个数都是 3,实际打出去的是 12 次。这个乘法在有 N 个客户端同时重试时还要再乘 N——这就是重试风暴的算术来源。同一段调用链上只保留一层重试,另一层要么删掉,要么显式设成 0。
排错:六个真实报错与修法
报错一:`requests.exceptions.RetryError: ... Max retries exceeded with url: /api (Caused by ResponseError('too many 503 error responses'))`
- 现象:配了
status_forcelist=[503]之后,请求在几毫秒内连打 4 次,最后抛RetryError。 - 原因:这是重试正常工作的表现,不是故障。真正的问题是它打得太快——
backoff_factor默认 0,四次请求 0.005 秒打完。 - 修法:给退避,并给整体上限。
Retry(total=3, status_forcelist=[500, 502, 503, 504],
backoff_factor=0.5, backoff_max=8,
allowed_methods=frozenset(['GET', 'POST'])) # POST 要显式加
报错二:`requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))`
- 现象:服务端主动掐断连接,客户端一次就失败,没有任何重试。
- 原因:默认适配器是
Retry(0, read=False),read=False让读错误直接 re-raise,根本进不了重试流程。 - 修法:显式挂适配器。实测换
Retry(total=3)后连接数从 1 次变 4 次。
s = requests.Session()
s.mount('http://', HTTPAdapter(max_retries=Retry(total=3)))
s.mount('https://', HTTPAdapter(max_retries=Retry(total=3)))
配套的自查命令(确认你拿到的到底是什么):
print(requests.Session().get_adapter('https://x/').max_retries)
# Retry(total=0, connect=None, read=False, redirect=None, status=None)
报错三:`requests.exceptions.JSONDecodeError: Expecting value: line 1 column 1 (char 0)`
- 现象:
r.json()炸在一个完全不相关的报错上,排查方向全错。 - 原因:上游返回的是 503 的 HTML 错误页,而默认配置下 503 不会被重试,requests 把它当成正常响应交回给你,
.json()才炸。 - 修法:两条一起做——把 5xx 加进
status_forcelist,同时在业务侧显式判状态码。
r = sess.get(url, timeout=5)
if r.status_code >= 500: # 别等 .json() 告诉你
raise RuntimeError(f'上游 5xx: {r.status_code}')
data = r.json()
报错四:`tenacity.RetryError[]`
- 现象:函数确实重试了 3 次,但
except ValueError分支一次都没进,日志里只剩一个RetryError。 - 原因:默认
reraise=False,tenacity 把最后一次的异常包进了RetryError(源码里retry_exc = self.retry_error_cls(fut),只有self.reraise为真才调retry_exc.reraise())。 - 修法:
reraise=True。实测加上之后抛出的就是ValueError。
@T.retry(stop=T.stop_after_attempt(3), wait=T.wait_fixed(0), reraise=True)
def boom(): ...
报错五:`requests.exceptions.InvalidHeader: Invalid Retry-After header: soon-ish`
- 现象:上游返回一个非标准的
Retry-After(既不是整数秒也不是合法 HTTP 日期),请求直接抛InvalidHeader,一次都没重试。 - 原因:
Retry.parse_retry_after()先用正则^\s[0-9]+\s$匹配整数,不匹配就交给email.utils.parsedate_tz,两者都失败就raise InvalidHeader。这是按规范办事,但会把你原本想做的重试整个打断。 - 修法:关掉对
Retry-After的盲从,改用自带的退避。
Retry(total=3, status_forcelist=[429, 503], respect_retry_after_header=False,
backoff_factor=0.5, backoff_max=8)
顺带一提,Retry-After 是有上限的:DEFAULT_RETRY_AFTER_MAX = 21600(6 小时),源码里 if seconds > self.retry_after_max: seconds = self.retry_after_max。所以上游给一个夸张的值也不会让你睡死,但 6 小时仍然很长。
报错六:程序卡住不动 —— wait_exponential 没有 max
- 现象:重试逻辑跑着跑着像假死,CPU 空闲,日志停更。
- 原因:
wait_exponential(multiplier=1)的默认max是 4.61e18 秒,等于没有上限。实测第 20 次要等 524288 秒(6.07 天)。 - 修法:永远显式给
max,并叠一个stop_after_delay兜底。
@T.retry(wait=T.wait_exponential(multiplier=1, max=10),
stop=(T.stop_after_attempt(5) | T.stop_after_delay(30)), # 注意是 OR
reraise=True)
def call(): ...
一份能直接抄的配置
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import tenacity as T
# 传输层:只负责「这一次连接 / 这一个响应要不要再来一遍」
RETRY = Retry(
total=3,
connect=3,
read=2,
status=3,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(['GET', 'POST']), # POST 必须显式加
backoff_factor=0.5,
backoff_max=8,
backoff_jitter=0.3, # 打散重试时刻
respect_retry_after_header=True,
raise_on_status=False, # 用尽后返回响应,交给业务层判
)
session = requests.Session()
session.mount('http://', HTTPAdapter(max_retries=RETRY, pool_maxsize=20))
session.mount('https://', HTTPAdapter(max_retries=RETRY, pool_maxsize=20))
# 应用层:只负责「整段业务逻辑要不要再来一遍」,且必须给 stop 和 max
@T.retry(
wait=T.wait_random_exponential(multiplier=1, max=10), # 带抖动的指数退避
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'上游 5xx: {r.status_code}', response=r)
return r.json()
两段配置的分工是刻意的:传输层管单个请求,应用层管整段逻辑,且应用层只对瞬时故障(超时、连接错误)重试,业务性 5xx 抛出来让调用方决定。如果像我实测那样两层都无差别重试,得到的是 12 次请求。
四步自查清单
1. 先看拿到的 Retry 对象是什么:Session().get_adapter(url).max_retries。默认那串是 Retry(total=0, connect=None, read=False, ...),等于不重试。
2. 看方法在不在白名单里:POST 默认不在,allowed_methods 要么显式加、要么设 None。
3. 看 status_forcelist 有没有写:不写就只有带 Retry-After 的 429/503/413 会重试。
4. 看 backoff_factor 是不是 0:是 0 就是零退避,重试只会放大瞬时压力。
FAQ
Q:为什么我配了 max_retries=3,服务端还是只收到一次请求?
A:三种可能,按概率排:请求方法是 POST(不在默认白名单);状态码不在 status_forcelist 且没有 Retry-After 头;你用的是顶层 requests.get() 而不是挂了适配器的 Session。实测这三种都表现为「只发一次且无异常」。
Q:total=3 到底是 3 次还是 4 次请求?
A:4 次。total 是重试次数,不含首次。实测服务端计数为 4。
Q:tenacity 和 urllib3 的重试能只留一个吗?
A:建议只留一个。留传输层:能覆盖连接与状态码,但对「业务逻辑整体失败」无效。留应用层:语义清楚、可控,但每次重试都会新建一次完整请求。两层同时无差别重试,实测是 3 x 4 = 12 次请求。
Q:stop_after_delay 是从什么时候开始计时的?
A:从 Retrying 对象 begin() 那一刻算起,包含重试之间的等待时间。所以它和 wait 一起消耗同一个预算——这也是为什么建议用 | 把 stop_after_attempt 和 stop_after_delay 组合起来。
Q:重试真的能把成功率提上去吗?
A:只对瞬时故障有效。对持续性 5xx、配额耗尽、参数错误,重试只是把同样的失败放大 N 倍,还会占住你的连接池。判断标准很简单:这个错误在 200 毫秒后会不会自己消失?
相关文章
- Python logging 实测:多进程丢日志、重复输出与 6 个真实报错——重试打出来的日志到底有没有落盘,是另一个独立的失效面。
- Python 依赖约束踩坑:~= 位数差一位、== 会忽略本地版本——同一条「默认值不是你以为的那个」的线索,在依赖解析层又出现一次。
- n8n Task Runner 架构解剖:external 与 queue 模式、五个真实报错根因——超时与重试在自托管服务里的另一组形态。
本文所有数字均为本机实测(Python 3.13.14 / requests 2.34.2 / urllib3 2.8.0 / tenacity 9.2.1),服务端为本地回环地址上的计数服务,不涉及任何第三方线上接口,也不含任何商品推广链接。抽取的源码片段来自本机已安装的包,官方文档口径来自 requests API 文档、urllib3.util.Retry 参考页与 tenacity 官方文档,抓取日期 2026-10-24。版本行为可能随升级变化,落地前请在自己的环境里复跑一遍上面的计数脚本。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):