先问一句:你上一次被 429 限流,你的程序等了几秒再重试? 如果答案是"固定等 1 秒"或者"没算过",这篇文章就是写给你的------下面这个封装能让 50 条突发请求在 3 秒不可用期之后 50/50 全部拿下,而固定重试在同样的场景里是 0/50 全军覆没。
TL;DR:我给 API 调用包了一层 BackoffClient:指数退避(200ms 起步,每次翻倍)+ 等抖动(保底一半,再抖一半),上限 8000ms,最多重试 5 次,只对 429 重试。拿两份模拟流水跑了一遍:突发 50 条、服务端前 3 秒不可用------固定重试 0/50,指数退避 40/50,全抖动 25/50,等抖动 50/50;持续压力 200 条、每秒 25 条------固定重试 189/200,指数退避和等抖动都是 200/200。数字是本地模拟的,类是直接能用的。
为什么要包这一层
被 429 限流之后怎么等,看起来是个小事,实际是个时间预算问题。调用量一上来,三个问题同时出现:
- 固定重试会先把子弹打光------固定等 500ms 重试 5 次,2.5 秒就烧完了;服务端不可用 3 秒,你的 5 次机会全撞在不可用期里,结果是 0/50,不是"慢一点"。
- 指数退避解决了撑多久,没解决一起撞上来------等得越久越好,但恢复那一刻,所有请求在同一毫秒重试,30 条撞上 20 个令牌的上限,注定丢 10 条。
- 全抖动有个真实短板------等待在 0, 上限 内随机,平均等待只有上限的一半,重试来得太早,预算烧得比不加抖动还快,25/50 比 40/50 还差。
这三个问题有个共同点:都不是缺重试次数,是缺一个"等得够久、又不扎堆"的地方。BackoffClient 就是这个地方:指数退避拉长时间跨度,等抖动打散同步重试,上限给等待封顶。
完整封装类
python
import os
import random
import time
BASE_URL = "https://YOUR_API_BASE_URL" # 占位符,换成你自己的接口地址
API_KEY = os.environ.get("YIMIAGENT_KEY", "") # Key 别写死,从环境变量读
class BackoffClient:
"""限流退避客户端:指数退避 + 等抖动,上限 8000ms,最多重试 5 次,只对 429 重试。"""
def __init__(self, call_fn, base_ms=200, cap_ms=8000, max_retries=5, rng=None):
self.call_fn = call_fn
self.base_ms = base_ms
self.cap_ms = cap_ms
self.max_retries = max_retries
self.rng = rng or random.Random()
self.stats = {"total": 0, "ok": 0, "retries": 0, "rate_limited": 0}
def _wait_ms(self, attempt):
exp = min(self.cap_ms, self.base_ms * (2 ** attempt))
return exp / 2 + self.rng.uniform(0, exp / 2) # 等抖动:保底一半,再抖一半
def call(self):
"""一次调用。call_fn 返回 (是否成功, 状态码)。只对 429 重试。"""
self.stats["total"] += 1
for attempt in range(self.max_retries + 1):
ok, status = self.call_fn()
if ok:
self.stats["ok"] += 1
return True
if status != 429:
return False # 不是限流,不重试
self.stats["rate_limited"] += 1
if attempt == self.max_retries:
return False
self.stats["retries"] += 1
time.sleep(self._wait_ms(attempt) / 1000.0)
return False
设计上只有两个坚持:第一,call_fn 只返回 (是否成功, 状态码),类里面不碰任何具体的模型名字和地址------换接口就是换一个传入的函数;第二,只对 429 重试,400、401、403 这类"换谁都没用"的错直接返回,不进重试、不烧预算。
注意:_wait_ms 里的等抖动是刻意选的。等待 = 上限/2 + 0, 上限/2 内的随机数------保底等一半,再抖一半。第 1 次重试等 100--200ms,第 5 次等 3100--6200ms。不要用全抖动(0, 上限 内随机):平均等待只有上限的一半,重试来得太早,模拟里它只拿到 25/50。
3 个调用示例
下面 3 个示例用的是同一份模拟后端(种子 20261113,结果可复现):服务端令牌桶容量 20、每秒补充 20 个令牌;场景 1 里服务端前 3000ms 不可用,之后恢复;四种策略的等待时间由同一个 delay_for 算出------A 固定 500ms,B 指数退避,C 指数退避 + 全抖动,D 指数退避 + 等抖动(就是 BackoffClient 的策略)。
python
import heapq
BUCKET_CAP = 20
REFILL_PER_SEC = 20
OUTAGE_MS = 3000
MAX_RETRIES = 5
SEED = 20261113
def delay_for(strategy, attempt, rng):
if strategy == "A":
return 500
exp = min(8000, 200 * (2 ** attempt))
if strategy == "B":
return exp
if strategy == "C":
return rng.uniform(0, exp) # 全抖动
return exp / 2 + rng.uniform(0, exp / 2) # 等抖动
def simulate(strategy, n=50, seed=SEED):
"""场景 1:突发 50 条同时到达,服务端前 3 秒不可用,之后令牌桶 20/秒。"""
rng = random.Random(seed)
events = []
seq = 0
for req in range(n):
heapq.heappush(events, (0, seq, req, 0))
seq += 1
tokens = float(BUCKET_CAP)
last_t = 0.0
attempts = rejected = success = failed = 0
last_success_t = 0.0
while events:
t, _, req, attempt = heapq.heappop(events)
tokens = min(float(BUCKET_CAP), tokens + (t - last_t) / 1000.0 * REFILL_PER_SEC)
last_t = t
if t < OUTAGE_MS:
ok = False
elif tokens >= 1:
tokens -= 1
ok = True
else:
ok = False
attempts += 1
if ok:
success += 1
last_success_t = max(last_success_t, t)
else:
rejected += 1
if attempt < MAX_RETRIES:
heapq.heappush(events, (t + delay_for(strategy, attempt, rng), seq, req, attempt + 1))
seq += 1
else:
failed += 1
return {"success": success, "failed": failed, "attempts": attempts,
"rejected_429": rejected, "makespan_ms": last_success_t}
def simulate_steady(strategy, n=200, per_sec=25, seed=SEED):
"""场景 2:持续压力 200 条,每秒 25 条,无不可用期。"""
rng = random.Random(seed)
events = []
seq = 0
for req in range(n):
heapq.heappush(events, (req * (1000 // per_sec), seq, req, 0))
seq += 1
tokens = float(BUCKET_CAP)
last_t = 0.0
attempts = rejected = success = failed = 0
last_success_t = 0.0
while events:
t, _, req, attempt = heapq.heappop(events)
tokens = min(float(BUCKET_CAP), tokens + (t - last_t) / 1000.0 * REFILL_PER_SEC)
last_t = t
if tokens >= 1:
tokens -= 1
attempts += 1
success += 1
last_success_t = max(last_success_t, t)
else:
attempts += 1
rejected += 1
if attempt < MAX_RETRIES:
heapq.heappush(events, (t + delay_for(strategy, attempt, rng), seq, req, attempt + 1))
seq += 1
else:
failed += 1
return {"success": success, "failed": failed, "attempts": attempts,
"rejected_429": rejected, "makespan_ms": last_success_t}
示例 1:固定重试的死法------突发 50 条,3 秒不可用
python
r = simulate("A") # 固定间隔 500ms,最多重试 5 次
print(f"成功 {r['success']}/50,失败 {r['failed']},总尝试 {r['attempts']} 次,429 共 {r['rejected_429']} 次")
输出:成功 0/50,失败 50,总尝试 300 次,429 共 300 次。5 次重试分别在 500、1000、1500、2000、2500ms 发起,全部撞在 3 秒不可用期里------第 5 次重试在 2500ms 发起时,服务端还没恢复。固定重试的问题不是"慢",是重试预算在故障恢复之前就花光了。
示例 2:指数退避 vs 等抖动------同一场突发
python
for s in ("B", "C", "D"):
r = simulate(s)
done = f"{r['makespan_ms']:.0f}ms" if r["success"] else "未完成"
print(f"策略 {s}:成功 {r['success']}/50,失败 {r['failed']},"
f"总尝试 {r['attempts']} 次,429 共 {r['rejected_429']} 次,全部完成耗时 {done}")
输出:策略 B:成功 40/50,失败 10,总尝试 280 次,429 共 240 次,全部完成耗时 6200ms;策略 C:成功 25/50,失败 25,总尝试 300 次,429 共 275 次,全部完成耗时 5281ms;策略 D:成功 50/50,失败 0,总尝试 300 次,429 共 250 次,全部完成耗时 5770ms。B 丢的 10 条在恢复那一刻:30 条请求在 6200ms 同时重试,令牌桶只有 20 个令牌;C 的 25/50 是因为全抖动平均等待只有上限的一半,重试来得太早;D 是四者里唯一 50/50 的,5770ms 还比 B 快 430ms------重试打散之后,恢复后的请求陆续到达,令牌桶有时间补充。
示例 3:持续压力 200 条,每秒 25 条
python
for s in ("A", "B", "D"):
r = simulate_steady(s)
done = f"{r['makespan_ms']:.0f}ms" if r["success"] else "未完成"
print(f"策略 {s}:成功 {r['success']}/200,失败 {r['failed']},"
f"总尝试 {r['attempts']} 次,429 共 {r['rejected_429']} 次,全部完成耗时 {done}")
输出:策略 A:成功 189/200,失败 11,总尝试 282 次,429 共 93 次,全部完成耗时 8540ms;策略 B:成功 200/200,失败 0,总尝试 282 次,429 共 82 次,全部完成耗时 11040ms;策略 D:成功 200/200,失败 0,总尝试 323 次,429 共 123 次,全部完成耗时 10815ms。平时差别不大:A 最快但丢 11 条,B 的 429 最少(等得最久、令牌补得最充分),D 多 41 次 429 换来的是故障期的 50/50。选 B 还是选 D,看你更在乎平时 429 最少,还是故障期全活下来。
四种策略对比
| 策略 | 场景 1:突发 50 条 + 3 秒不可用 | 场景 2:持续 200 条,每秒 25 条 |
|---|---|---|
| A. 固定间隔 500ms | 0/50,全军覆没 | 189/200,最快 8540ms,丢 11 条 |
| B. 指数退避 | 40/50,6200ms | 200/200,429 最少 82 次 |
| C. 指数退避 + 全抖动 | 25/50,最差 | 199/200,429 最多 168 次 |
| D. 指数退避 + 等抖动(本文) | 50/50,5770ms | 200/200,429 共 123 次 |
C 的两行是最值得记住的反常识结果:全抖动在两个场景里都是最差或接近最差。抖动本身没错,错的是"从 0 开始抖"------平均等待减半,重试预算烧得更快。等抖动(保底一半 + 抖一半)保留了指数退避的时间跨度,又打散了同步重试,两个场景都是满分。
诚实短板
这个封装有三个明确的短板,先说清楚:
- 上限和重试次数是经验值。8000ms 上限、5 次重试是按"3 秒不可用期"调的起点。如果你的服务端限流窗口是 60 秒,5 次重试撑 6.2 秒就不够;如果你的请求 2 秒就超时,8000ms 的上限就等得太久了。先跑一周,看重试耗尽失败数再定。
- 等抖动平时要多几次尝试。场景 2 里 D 的 429 是 123 次,比 B 的 82 次多 41 次------抖动的代价是平时多几次尝试。能接受就用,不能接受、且你的服务端从不出现数秒不可用,就用纯指数退避 B。
- 只对 429 重试。429 是"服务端让你等一等",等就有用;500 和超时可能是服务端真故障,等的意义不一样。这个类不处理 500/超时------接自己的接口时,429 和 500/超时各走一套重试次数,互不占用,不然一次 500 故障就能把 429 的重试预算也烧光。
总结
- 重试首先是时间预算:固定 500ms 重试 5 次只能撑 2.5 秒,3 秒不可用期直接 0/50。
- 指数退避把 5 次重试拉长到 6.2 秒,40/50;丢的 10 条是恢复那一刻 30 条同时撞 20 个令牌。
- 抖动要加在保底上:全抖动 25/50 反而更差;等抖动(保底一半 + 抖一半)50/50,5770ms 全部完成。
- 上限 8000ms、重试 5 次先跑一周,看 429 总数、重试总次数、重试耗尽失败数再调。
留个问题: 你的调用里,被 429 限流时一般等几秒?是固定间隔、指数退避,还是直接报错?贴在评论区,我挑几个有代表性的在下一篇里讲讲当时是怎么处理的。