封装一个限流退避器:指数退避 + 等抖动 + 最大等待的一次封装

先问一句:你上一次被 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 限流之后怎么等,看起来是个小事,实际是个时间预算问题。调用量一上来,三个问题同时出现:

  1. 固定重试会先把子弹打光------固定等 500ms 重试 5 次,2.5 秒就烧完了;服务端不可用 3 秒,你的 5 次机会全撞在不可用期里,结果是 0/50,不是"慢一点"。
  2. 指数退避解决了撑多久,没解决一起撞上来------等得越久越好,但恢复那一刻,所有请求在同一毫秒重试,30 条撞上 20 个令牌的上限,注定丢 10 条。
  3. 全抖动有个真实短板------等待在 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 开始抖"------平均等待减半,重试预算烧得更快。等抖动(保底一半 + 抖一半)保留了指数退避的时间跨度,又打散了同步重试,两个场景都是满分。

诚实短板

这个封装有三个明确的短板,先说清楚:

  1. 上限和重试次数是经验值。8000ms 上限、5 次重试是按"3 秒不可用期"调的起点。如果你的服务端限流窗口是 60 秒,5 次重试撑 6.2 秒就不够;如果你的请求 2 秒就超时,8000ms 的上限就等得太久了。先跑一周,看重试耗尽失败数再定。
  2. 等抖动平时要多几次尝试。场景 2 里 D 的 429 是 123 次,比 B 的 82 次多 41 次------抖动的代价是平时多几次尝试。能接受就用,不能接受、且你的服务端从不出现数秒不可用,就用纯指数退避 B。
  3. 只对 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 限流时一般等几秒?是固定间隔、指数退避,还是直接报错?贴在评论区,我挑几个有代表性的在下一篇里讲讲当时是怎么处理的。

相关推荐
丘山录2 小时前
llama.cpp 完整教程,放错盘读一遍多等 1 小时
人工智能
AI前端工程狮2 小时前
用户点了「取消」,我的服务进程退出了
人工智能·程序员
网易云信2 小时前
别急着让 AI 出方案:学会追问三步走,医美咨询全跑通
人工智能
panyz2 小时前
不想再开 7 个标签页查AI额度,我用 Node 写了个本地看板
人工智能·开源·github
炫酷的大高个2 小时前
3.2TB 权重开源了,我算了 4bit 之后还要几张卡
人工智能·架构·开源
字节跳动开源2 小时前
AI 原型几分钟,设计返工一整天?OpenViking 让 AI 用上你的 Figma 设计稿
人工智能·开源
陈工大模型4 小时前
解压工具的三个技术分水岭:编码、分卷、容器格式
人工智能·windows·安全·解压工具·免费国内办公软件
hpoenixf4 小时前
让 Agent 持续研究半小时:从普通研究到可靠交付的设计
人工智能
MysTic_Zhong4 小时前
【2】整体架构蓝图:六层依赖与技术选型
人工智能·agent