代码实战 · 2026 年 11 月 11 日

手写一个带重试与退避的 HTTP 客户端

重试不是 for 循环套三次。用标准库写一个带全抖动指数退避、超时预算、Retry-After 支持与幂等判断的 HTTP 客户端,并说清哪些请求根本不该重试。

凌晨的批量任务把上游打挂了。原因很简单:一次 502 之后,代码立刻用 for 循环重试三次,几千个请求在同一秒涌向已经过载的服务。

更糟的是日志:每个失败都打了三条 error,值班同学第一反应是「上游挂了」,实际上是我们自己把对方压垮的。后来我们把重试逻辑收进一个客户端,明确「等多久、试几次、什么时候放弃」,这类事故再没出现过。

重试本身不难,难的是「重试几次、等多久、哪些请求能重试、总时长怎么收口」。下面这版客户端只用标准库,四个问题都给出了默认答案。

先判断:这个请求值得重试吗#

三条规则,按优先级排:

  1. 只重试幂等请求。GET、HEAD、PUT、DELETE、OPTIONS 重复发送不会产生额外副作用;POST 会,默认只发一次。如果上游支持幂等键,可以带上 Idempotency-Key 再放开。
  2. 只重试可恢复的状态码。408(请求超时)、425(过早)、429(限流)、500、502、503、504 值得再试;400、401、403、404、422 是请求本身有问题,重试一百次结果一样。
  3. 只重试传输层失败。连接被拒、连接重置、读超时、TLS 握手失败,这些都能重试。DNS 解析失败通常也可以,但要先确认不是域名打错了。

判断顺序建议从右往左:先看总预算还剩多少,再看状态码是否可恢复,最后才看方法是否幂等。反过来做,很容易在一个已经超时的请求上继续排队等待。

情况 处理 原因
POST 创建订单 不重试 可能重复下单
429 带 Retry-After: 30 等待 30 秒后重试 服务端明确告知节奏
503 无 Retry-After 指数退避 对方在过载,退让
401 token 过期 刷新 token 后重发一次 与网络无关
读超时 5 秒 重试 请求可能只是慢
总耗时超过预算 立即失败 不再放大故障

退避:为什么要加抖动#

固定间隔(比如每次等 1 秒)最糟:所有客户端会在同一时刻再次齐射,把刚缓过来的服务再打趴。指数退避把间隔拉长,全抖动(full jitter)再把时间打散到 [0, cap] 区间内。

公式是 random.uniform(0, min(cap, base × 2^attempt))。第 0 次失败最多等 0.5 秒,第 1 次 1 秒,第 2 次 2 秒,封顶 8 秒。看起来比「等满 2 秒」激进,但期望值更小,整体恢复更快,这是 AWS 架构博客里推荐的默认策略。

顺便算一下重试的收益。假设单次失败率是 1%,独立重试 3 次全部失败的概率约为 0.01³ = 10⁻⁶;但如果是持续性故障(实例整个挂了),单次失败率就是 100%,重试几次都是 100%。所以重试只对「偶发、独立」的失败有效,对容量问题无效,后者要靠限流和扩容。

python
# retry_client.py —— 仅标准库,Python 3.9+
import http.client
import random
import socket
import ssl
import time
import urllib.error
import urllib.request

RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}

class RetryExhausted(RuntimeError):
    def __init__(self, url, attempts, last):
        super().__init__(f"{url} 重试 {attempts} 次仍失败: {last!r}")
        self.last = last

def backoff(attempt, base=0.5, cap=8.0):
    """全抖动指数退避:第 n 次最多等 min(cap, base*2^n) 秒。"""
    return random.uniform(0, min(cap, base * 2 ** attempt))

def retry_after(value, fallback):
    """只解析秒数;HTTP-date 形式退回退避值,宁可保守也不乱等。"""
    if not value:
        return fallback
    try:
        return max(0.0, float(value.strip()))
    except ValueError:
        return fallback

主循环:用总预算收口#

python
def request(url, *, method="GET", data=None, headers=None, timeout=5.0,
            attempts=4, budget=20.0, retry_non_idempotent=False):
    method = method.upper()
    if method not in IDEMPOTENT and not retry_non_idempotent:
        attempts = 1                       # 非幂等请求默认只发一次
    headers = dict(headers or {})
    started, last = time.monotonic(), None

    for attempt in range(attempts):
        remain = budget - (time.monotonic() - started)
        if remain <= 0:
            raise RetryExhausted(url, attempt, last)
        req = urllib.request.Request(url, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=min(timeout, remain)) as resp:
                return resp.status, dict(resp.headers), resp.read()
        except urllib.error.HTTPError as e:
            body = e.read()                # 必须读完,连接才能回收
            last = e
            if e.code not in RETRY_STATUS or attempt == attempts - 1:
                return e.code, dict(e.headers), body
            wait = retry_after(e.headers.get("Retry-After"), backoff(attempt))
        except (urllib.error.URLError, socket.timeout, ConnectionError,
                ssl.SSLError, http.client.HTTPException) as e:
            last = e
            wait = backoff(attempt)
        remain = budget - (time.monotonic() - started)
        if remain <= 0:
            break
        time.sleep(max(0.05, min(wait, remain)))   # 至少让出 50ms,避免空转
    raise RetryExhausted(url, attempts, last)

几个刻意的选择:

  • timeout=min(timeout, remain)。单次超时不能超过剩余预算,否则一次卡死会吃掉全部重试机会。
  • Retry-After 优先。服务端说 30 秒就等 30 秒,仍然受预算约束;解析失败才退回退避值。
  • 4xx 直接返回不抛异常。调用方需要读 401 的错误体和 429 的限流头,包成异常反而碍事。
  • e.read() 不能省。HTTPError 的响应体不读完,底层连接不会释放。
提示

urllib 的重试是「重新建立连接」,不是复用连接池。高并发场景下应该换 httpx 或 requests.Session,退避逻辑可以原样搬过去。

验证:起一个坏两次的本地服务#

python
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer

hits = {"n": 0}

class Flaky(BaseHTTPRequestHandler):
    def do_GET(self):
        hits["n"] += 1
        code = 503 if hits["n"] < 3 else 200       # 前两次失败,第三次成功
        self.send_response(code)
        self.send_header("Retry-After", "0.2")
        self.send_header("Content-Length", "2")
        self.end_headers()
        self.wfile.write(b"ok" if code == 200 else b"no")

    def log_message(self, *args):
        pass

srv = HTTPServer(("127.0.0.1", 0), Flaky)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_port}/health"
print(request(url, attempts=4, budget=10.0))
srv.shutdown()

跑出来是 (200, {...}, b'ok'),总耗时约 0.4 秒——两次 Retry-After: 0.2 的结果。把 Retry-After 改成 30,预算 10 秒会直接抛 RetryExhausted,这比干等 30 秒再失败更符合调用方的预期。

这个本地服务值得留在测试里。它能在不依赖外网的情况下覆盖三条关键路径:可恢复的 5xx、Retry-After 生效、预算耗尽。把失败次数改成 10,再断言 hits["n"] == attempts,就能确认重试次数没有失控。

生产环境还要考虑什么#

  • 重试放大。客户端重试 3 次、网关重试 3 次、服务内部再重试 3 次,一次请求会变成 27 次。只在一层做重试,其他层快速失败。
  • 熔断。连续失败超过阈值就停止请求一段时间,否则退避只是把雪崩拉长。重试解决「偶发」,熔断解决「持续故障」。
  • 可观测。至少记录 url、attempt、status、wait、total。只统计最终成功率会掩盖重试风暴。
  • 不要重试到超时上限。上游 SLA 是 2 秒,你的预算是 20 秒,重试成功也晚了。预算应该由调用方的 deadline 反推。
  • 请求体要可重放。data 是 bytes 才能重发;如果传的是文件流,第二次发送时指针已在末尾,会发出空 body。
  • 区分错误类型再决定告警。RetryExhausted 里的 last 保留了最后一次异常,据此区分「全是 5xx」「全是超时」「混合」,排障时能少走很多弯路。
  • 别把重试写进业务代码。同一个项目里出现五套重试参数是常态,最后没人说清哪个请求被放大了几次。统一收口到客户端,参数按接口配置。

结论#

  • 幂等性决定能不能重试,状态码决定值不值得重试,超时预算决定允许重试多久。
  • 退避用全抖动指数,base=0.5s、cap=8s 是个稳妥起点。
  • Retry-After 优先于自己的退避算法,但仍要受总预算约束。
  • 4xx 不重试(429 除外),连接层错误才值得重连。
  • 一次请求只能在一层重试,多层叠加会把故障放大成风暴。
  • 重试是给偶发失败留的余量,不是给容量不足兜底;持续故障要靠限流、扩容和熔断。

完

#Python#HTTP#重试
回到顶部 ↑