← 返回首页

重试为什么没生效:requests 2.34.2 与 tenacity 9.2.1 的四处静默失效

Pythonrequests故障排查

TL;DR:这篇文章全部结论都来自本机实测——我在 127.0.0.1 上起了一个会数自己收到几次请求的 HTTP 服务,然后逐条改配置、逐条看服务端计数。结论是:默认 requests 一次都不重试;你配了 Retry(3) 也不等于会重试;就算真的重试了,backoff_factor 默认 0 意味着四次请求会在几毫秒内打完;而应用层的 tenacity 默认会把你的原始异常换成 RetryError,让 except ValueError 永远抓不到。两层都配时,重试次数是相乘的。

实测环境与可复现前提

组件版本出处
Python3.13.14本机 sys.version
requests2.34.2PyPI 上传于 2026-05-14
urllib32.8.0PyPI 上传于 2026-09-15
tenacity9.2.1PyPI 上传于 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: 143.009 s重试 3 次,每次按响应头等 1 秒,最后返回 503
503,无 Retry-After10.003 s一次就返回
500 + Retry-After: 110.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.」

两个实测:

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

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

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 = sess.get(url, timeout=5)
if r.status_code >= 500:          # 别等 .json() 告诉你
    raise RuntimeError(f'上游 5xx: {r.status_code}')
data = r.json()

报错四:`tenacity.RetryError[]`

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

@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 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 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页