requests + urllib3 Retry:为幂等方法配置指数退避并处理 429

举报
yd_217513249 发表于 2026/10/24 00:33:16 2026/10/24
【摘要】 介绍如何用 requests 的 HTTPAdapter 配合 urllib3 的 Retry 统一配置重试策略:只对 GET/HEAD/PUT/DELETE 等幂等方法生效,把 429 纳入 status_forcelist 并尊重 Retry-After 头,同时让 POST 走不自动重试的通道以避免重复提交。文中给出可直接运行的最小示例和本地验证服务器,并说明超时叠加、版本差异等边界。

问题场景:重试写在业务循环里,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)后,再决定是否做安全重发。

边界与限制

  1. allowed_methods 主要约束由状态码触发的重试。连接阶段的错误是否遵守该集合,与 urllib3 版本有关;对写操作要求严格时,直接使用不挂重试适配器的 Session 更省心。
  2. 重试次数与 timeout 会叠加。最坏情况下总耗时接近各次超时之和加上退避等待,需要在外层用总时限或任务超时兜底。
  3. 不建议把 400、401、403、404 等客户端错误放进 status_forcelist,这些错误重试通常不会改变结果。
  4. 版本差异需要留意:urllib3 1.26 起用 allowed_methods 取代了旧的 method_whitelist,旧代码迁移时容易漏改;部分参数在较老版本中不存在,运行前请确认当前环境版本。
  5. 退避重试解决的是“请求没成功”的场景,无法解决“服务端已处理但响应丢失”。写操作仍应依赖服务端幂等键,不能只靠客户端不重试。

小结

把重试配置收敛到 HTTPAdapter + Retry,用 allowed_methods 划出幂等方法边界,用 status_forcelist 圈定需要重试的状态码,并开启 respect_retry_after_header 尊重 429 的 Retry-After。写请求单独走不重试的通道,配合服务端幂等键,才能在提升成功率的同时避免重复提交。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。