requests + urllib3 Retry:为幂等方法配置指数退避并处理 429
问题场景:重试写在业务循环里,POST 也被重发了
调用第三方接口时,偶发 503、429 很常见。一种常见做法是在业务代码里写 for 循环重试,或者在调用处套一层 try/except。这两种写法有两个隐患:一是容易把 POST 也一起重试,造成重复创建订单、重复扣款;二是遇到 429 时忽略响应里的 Retry-After,用固定的间隔反复冲击对端,反而延长了限流时间。
更稳妥的方式是把重试策略下沉到传输层:用 requests 的 HTTPAdapter 挂载 urllib3 的 Retry 对象,让重试次数、退避间隔、允许重试的方法与状态码集中配置一次,业务代码保持干净。
关键配置项
total:总重试次数上限(不含首次请求)。connect/read/status:分别限制连接失败、读取超时、状态码触发的重试次数。backoff_factor:退避因子,urllib3 会基于它计算递增的等待时间。具体序列随版本实现变化,建议结合日志观察,不要直接写死预期值。status_forcelist:只有这些状态码才触发状态码重试。要处理 429,建议显式把它放进来,策略更易读。allowed_methods:允许重试的 HTTP 方法集合。urllib3 默认已排除 POST/PATCH,但显式写出可以避免后续被误改。respect_retry_after_header:为True时,若响应带Retry-After,urllib3 会优先按该响应头决定等待时长。
最小示例:构建带重试的 Session
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# 只允许幂等方法被重试;POST / PATCH 不在此列
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"})
RETRY_STATUS = (429, 500, 502, 503, 504)
def build_session(
total: int = 4,
backoff_factor: float = 0.5,
status_forcelist=RETRY_STATUS,
allowed_methods=IDEMPOTENT_METHODS,
) -> requests.Session:
retry = Retry(
total=total,
connect=total,
read=total,
status=total,
other=0,
backoff_factor=backoff_factor,
status_forcelist=status_forcelist,
allowed_methods=allowed_methods,
respect_retry_after_header=True,
raise_on_status=False,
raise_on_redirect=False,
)
adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
session = requests.Session()
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
使用示例(域名与参数均为占位值):
session = build_session()
try:
resp = session.get(
"https://api.example.com/v1/items",
params={"page": 1},
timeout=(3.05, 10), # (连接超时, 读取超时),单次请求的超时
)
print(resp.status_code, resp.text[:200])
except requests.RequestException as exc:
print("最终失败:", type(exc).__name__)
# 写请求走普通 Session。requests 默认 max_retries=0,即不自动重试
write_session = requests.Session()
write_session.post(
"https://api.example.com/v1/orders",
json={"sku": "demo-001", "qty": 1},
timeout=(3.05, 10),
)
一个容易忽略的点:只有给请求设置了 timeout,read 超时重试才有意义。timeout 是每次请求的超时,不是整段重试的总耗时上限。
本地验证:确认 GET 重试、POST 不重试
下面用一个标准库写的小服务器做验证,前两次对同一方法的请求返回 503,之后返回 200。保存为 verify_retry.py 后直接运行。
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class FlakyHandler(BaseHTTPRequestHandler):
hits = {"GET": 0, "POST": 0}
lock = threading.Lock()
def _reply(self):
length = int(self.headers.get("Content-Length") or 0)
if length:
self.rfile.read(length)
with self.lock:
self.hits[self.command] = self.hits.get(self.command, 0) + 1
n = self.hits[self.command]
if n == 1:
self.send_response(503)
body = b'{"error": "temporary"}'
else:
self.send_response(200)
body = json.dumps({"method": self.command, "hit": n}).encode()
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = _reply
do_POST = _reply
def log_message(self, *args):
pass
def start_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), FlakyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
def main():
server = start_server()
base = f"http://127.0.0.1:{server.server_address[1]}"
retry = Retry(
total=3,
backoff_factor=0.2,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET", "HEAD", "PUT", "DELETE"}),
respect_retry_after_header=True,
raise_on_status=False,
)
session = requests.Session()
session.mount("http://", HTTPAdapter(max_retries=retry))
ok = session.get(f"{base}/resource", timeout=2)
print("GET ->", ok.status_code, ok.json(), "GET 到达服务端次数:", FlakyHandler.hits["GET"])
blocked = session.post(f"{base}/resource", json={"name": "demo"}, timeout=2)
print("POST ->", blocked.status_code, "POST 到达服务端次数:", FlakyHandler.hits["POST"])
server.shutdown()
if __name__ == "__main__":
main()
运行方法:python verify_retry.py。GET 会命中服务端两次(第一次 503,第二次 200),最终打印 200;POST 只命中一次并返回 503。这说明状态码重试确实被限制在了幂等方法上,写请求没有被自动重发。
429 与 Retry-After
当 status_forcelist 包含 429 时,urllib3 会在重试前检查 Retry-After。该响应头的值是秒数(如 Retry-After: 2)或 HTTP 日期时,urllib3 会据此决定等待时长,替代基于 backoff_factor 的计算。需要注意两点:部分版本对等待时长存在内部上限,服务端给出很大的值时不一定会完全等待;HTTP 日期格式依赖双方时钟,跨机器时间偏差会影响解析结果。因此生产环境里建议同时开启日志观察实际等待,并保留客户端侧的限速与降级策略。
对 POST 遇到 429 的情况,由于 allowed_methods 不含 POST,自动重试不会发生。这时应由业务层处理:可以读取 Retry-After 提示用户稍后再试,也可以在写入接口上使用幂等键(如订单号、请求 ID)后,再决定是否做安全重发。
边界与限制
allowed_methods主要约束由状态码触发的重试。连接阶段的错误是否遵守该集合,与 urllib3 版本有关;对写操作要求严格时,直接使用不挂重试适配器的 Session 更省心。- 重试次数与
timeout会叠加。最坏情况下总耗时接近各次超时之和加上退避等待,需要在外层用总时限或任务超时兜底。 - 不建议把 400、401、403、404 等客户端错误放进
status_forcelist,这些错误重试通常不会改变结果。 - 版本差异需要留意:urllib3 1.26 起用
allowed_methods取代了旧的method_whitelist,旧代码迁移时容易漏改;部分参数在较老版本中不存在,运行前请确认当前环境版本。 - 退避重试解决的是“请求没成功”的场景,无法解决“服务端已处理但响应丢失”。写操作仍应依赖服务端幂等键,不能只靠客户端不重试。
小结
把重试配置收敛到 HTTPAdapter + Retry,用 allowed_methods 划出幂等方法边界,用 status_forcelist 圈定需要重试的状态码,并开启 respect_retry_after_header 尊重 429 的 Retry-After。写请求单独走不重试的通道,配合服务端幂等键,才能在提升成功率的同时避免重复提交。
- 点赞
- 收藏
- 关注作者
评论(0)