← 返回资讯

重试与指数退避怎么写:429、超时、熔断的完整容错策略

2026-08-07

线上出过一次事故,事后复盘时最刺眼的一段代码只有四行:

for i in range(3):
    resp = call_api(payload)
    if resp.ok:
        break

这段东西在每一个团队的代码库里都能找到同款。它平时看不出问题——因为平时几乎不会失败,重试根本轮不到执行。它只在上游真的出事的那一天才生效,而那一天,它做的事情是往一个已经跪下的服务身上再补几脚。

我见过不少人把重试当成”顺手加的健壮性”,加完就不管了。实际上重试是分布式系统里最容易反噬的一类代码:写对了它救你一命,写错了它是放大器。这篇把该怎么写讲透。

重试风暴:为什么”加个循环”是危险的

先把机制说清楚,因为不理解机制的人永远会把重试写错。

假设上游服务因为容量不足开始拒绝请求。此刻它的处境是:进来的请求比它能处理的多。它返回错误,本质上是在喊”别发了”。

而此时你的客户端做了什么?收到失败,立刻重发。不只是你,所有调用方都在同一秒收到失败,所有调用方都立刻重发。原本每秒 1 万个请求,现在变成 2 万——上游要处理的连接数、要走的鉴权、要写的日志一样不少,只是最后全部以错误结尾。它更处理不过来了,于是失败率进一步上升,于是重试量进一步上升。

这就是重试风暴(retry storm):重试量与失败率互为因果,形成正反馈。它的可怕之处在于,即使上游的原始故障已经恢复(比如某个慢查询已经过去了),持续涌入的重试流量也能让它一直起不来。运维圈里管这个叫”服务被自己的客户端按在地上”。

还有一个更隐蔽的版本:重试的乘法效应。如果你的调用链是 A → B → C,每一层都写了”重试 3 次”,那么 C 实际承受的请求量是原始量的 3×3=9 倍。链路每深一层,放大系数就乘一次。所以有一条经验法则值得记住:重试只在一层做,通常是最靠近用户的那一层,或者最靠近上游的那一层,但不要每一层都做。

理解了这个,下面所有的设计选择就都有了动机:我们要做的不是”失败了就再试”,而是”在不伤害上游的前提下,把那些确实值得再试一次的请求救回来”。

第一件事:不是所有失败都该重试

这一步做错,后面写得再精巧也是白搭。很多人的重试逻辑是”只要 resp.ok 是 false 就重试”,这等于把一大堆永远不可能成功的请求反复发出去,烧钱、烧时间、还占着连接。

按 HTTP 状态码语义分三类:

类别典型状态码 / 情况该不该重试原因
该重试429限流是临时的,等一会儿配额就回来了
该重试500 / 502 / 503 / 504服务端侧问题,换个时刻或换个实例可能就好了
该重试连接被重置、DNS 抖动、TLS 握手失败网络层瞬时故障,重试成功率相当高
不该重试400 / 422不该请求体本身有问题,重试一万次还是同样的错
不该重试401 / 403不该凭证或权限问题,重发不会让密钥突然变有效
不该重试404不该路径或资源不存在,属于配置问题
要小心请求超时、读超时看情况可能已经在服务端执行过了,见下一节

400 这一类尤其值得单独强调。我见过一个把 max_tokens 传成非法值的脚本,它在重试逻辑里循环了 5 次才放弃,然后外面还套了一层任务级重试,于是一个必然失败的请求被发了 25 次。这些请求每一次都要走完整的网络往返,把日志刷满,还让监控上的错误率图形完全失真——你以为上游炸了,其实是你自己的参数写错了。

判据很简单:如果错误的原因在你这边,重试没有意义;如果原因在对面或路上,重试才有意义。 4xx(除了 429)基本都属于前者,5xx 和网络错误属于后者。429 是个特例,它虽然是 4xx,但表达的是”现在不行,等会儿再来”,所以归入该重试。关于 429 的成因、配额维度和排查方法,可以看 429 限流:指数退避与重试策略 里更细的展开。

还有一类容易被忽略的:响应体解析失败。有时候 HTTP 状态是 200,但返回的内容被截断、不是合法 JSON,或者流式响应中途断了。这种情况本质上是传输问题,可以重试,但要注意流式场景下你可能已经把半截内容吐给用户了,重试意味着要么从头再来(用户看到内容跳变),要么放弃。这个取舍最好在产品层面提前想清楚。

超时是个陷阱:它可能已经成功了

单独拎出来说,因为这一条踩坑的人最多。

客户端超时的含义是”我在规定时间内没收到响应”,它不等于”服务端没处理”。可能的真实情况有三种:

  1. 请求根本没到服务端(网络断了)——重试是安全的;
  2. 请求到了,服务端还在处理,只是比你的超时阈值慢——重试会导致同一个请求被执行两次;
  3. 请求到了,服务端处理完了,响应在回来的路上丢了——重试同样会导致执行两次。

你在客户端无法区分这三种情况。这是分布式系统里一个根本性的限制,不是靠写代码能绕过去的。

对纯生成类的调用(发一段提示词,拿一段文本回来),执行两次的后果只是多花一次钱、多等一会儿,可以接受。但如果你的链路里有副作用——收到结果后写库、扣减额度、触发一条通知、往队列里塞一条消息——那么”多执行一次”就是一次真实的数据事故。

所以规则是:对有副作用的操作,重试前必须先解决幂等性(本文最后一节展开)。在幂等机制没到位之前,超时的处理方式应该是保守的:要么不重试直接报错让上层决定,要么先查询一次状态确认前一次是否已经生效。

另外一个实操建议:把连接超时和读超时分开设。连接超时可以很短,因为连不上通常就是连不上,等再久也没用,而且连接阶段失败几乎可以确定服务端没收到请求,重试是安全的;读超时要设得宽松些,尤其是长文本生成场景,模型本身就要吐很久,把读超时设得太短会把大量本来会成功的请求人为变成失败,然后触发一轮不必要的重试。超时参数具体怎么定,可以参考 接入超时怎么设

退避算法:从固定间隔到抖动

假设我们已经筛出了”该重试”的请求,接下来的问题是:隔多久重试?

第一级:固定间隔

time.sleep(1)

最朴素的写法。它解决了”立刻重发”的问题——至少给了上游 1 秒喘息。但它有个致命缺陷:所有客户端的重试时刻是对齐的

想象一下:上游在 T 时刻开始返回错误,1000 个客户端在 T 到 T+0.1 秒之间陆续收到失败,然后它们全部在 T+1 秒左右重发。上游又收到一波齐刷刷的洪峰,又全部失败,然后 T+2 秒再来一波。你不是在给它恢复的机会,你是在给它做定时压测。

第二级:指数退避

delay = base * (multiplier ** attempt)

每失败一次,等待时间翻倍。这一级解决的是重试压力随时间衰减的问题:第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒。如果故障持续,客户端发出的重试频率会指数级下降,上游承受的额外压力会自动收敛。这比固定间隔进步巨大。

但它仍然没解决同步问题。所有客户端用的是同一条退避曲线,所以第一波在 T+1 齐发,第二波在 T+3 齐发,第三波在 T+7 齐发。峰值间隔拉长了,但每个峰仍然是尖的。这个现象叫惊群(thundering herd)——一群客户端像受惊的羊一样,永远在同一时刻一起动。

第三级:加抖动(关键的一步)

抖动(jitter)就是往退避时间里注入随机扰动,把原本对齐的重试时刻打散到一个区间里。这一步看起来很小,实际上是整套机制里最重要的一环:它把尖峰变成了平台,把 1000 个客户端在同一毫秒的冲击变成了在几秒内的均匀涓流。

常见的两种做法:

全抖动(full jitter)——在 [0, d] 区间内均匀取值,d 是当前这一轮的退避上限:

d    = min(cap, base * multiplier ** attempt)
wait = random.uniform(0, d)

打散效果最好,因为取值区间最宽。代价是可能抽到很小的值(比如第 5 次重试只等了 0.3 秒),单个请求的表现会显得不稳定。

等比抖动(equal jitter)——保底等一半,另一半随机:

d    = min(cap, base * multiplier ** attempt)
wait = d / 2 + random.uniform(0, d / 2)

保证了退避时间的下界随轮次增长,既有打散效果又不会退化成”几乎不等”。工程上我更常用这一种,因为它的行为更好预测,排查问题时不容易出现”为什么这次只等了一瞬间”的困惑。

一段可以直接抄的实现:

import random
import time

RETRYABLE_STATUS = {429, 500, 502, 503, 504}

def backoff_delay(attempt, base=1.0, multiplier=2.0, cap=30.0, mode="equal"):
    """attempt 从 0 开始计数,返回本轮应等待的秒数。"""
    d = min(cap, base * (multiplier ** attempt))
    if mode == "full":
        return random.uniform(0, d)
    # equal jitter
    return d / 2 + random.uniform(0, d / 2)

def call_with_retry(fn, max_attempts=5, budget=60.0):
    started = time.monotonic()
    last_error = None
    for attempt in range(max_attempts):
        try:
            resp = fn()
            if resp.status_code not in RETRYABLE_STATUS:
                return resp          # 成功,或不可重试的错误,都直接返回
            last_error = resp
            wait = parse_retry_after(resp) or backoff_delay(attempt)
        except (ConnectionError, TimeoutError) as e:
            last_error = e
            wait = backoff_delay(attempt)

        # 时间预算闸门:等下去会超预算,就别等了
        elapsed = time.monotonic() - started
        if elapsed + wait > budget or attempt == max_attempts - 1:
            break
        time.sleep(wait)
    raise RetryExhausted(last_error)

注意几个细节:用 time.monotonic() 而不是 time.time(),避免系统时钟被 NTP 调整时算出负数;不可重试的错误直接 return 而不是继续循环;最后一轮不再 sleep(等完了也不会再试,纯浪费时间)。

退避序列示例

下面这张表的参数是我自设的示例值(基数 1 秒、倍数 2、上限 30 秒),只用来演示曲线形状,不代表任何平台的推荐配置——实际参数要按你自己观测到的故障恢复时间来调:

重试轮次退避上限 d(秒)全抖动等待区间等比抖动等待区间
第 1 次10 ~ 10.5 ~ 1
第 2 次20 ~ 21 ~ 2
第 3 次40 ~ 42 ~ 4
第 4 次80 ~ 84 ~ 8
第 5 次160 ~ 168 ~ 16
第 6 次30(触顶)0 ~ 3015 ~ 30

把最后一列的上界加起来:1 + 2 + 4 + 8 + 16 + 30 = 61 秒。也就是说在这套参数下,六次重试最坏情况下光是”等待”就要花掉 61 秒,还不包括六次请求本身的耗时。如果这是一个用户正在页面上等结果的同步调用,那这套参数完全不能用。这就引出了下一节。

cap(退避上限)是必须设的。没有上限的话,第 10 次重试要等 512 秒,第 15 次要等四个多小时——这已经不叫重试了,叫遗忘。

Retry-After 优先于你的退避曲线

如果响应头里带了 Retry-After听它的,不要用你自己算出来的退避值。

理由很直接:你的退避曲线是盲猜,Retry-After 是上游告诉你的确定信息——它知道自己的配额窗口什么时候重置,你不知道。你猜 1 秒,它说要 20 秒,那你在这 20 秒里发的每一次请求都是注定失败的无效流量。反过来,你猜 30 秒,它说 2 秒就好了,那你白白多等了 28 秒。

Retry-After 有两种合法格式:

  • 秒数Retry-After: 20,表示等 20 秒后再来;
  • HTTP 日期Retry-After: Wed, 21 Oct 2026 07:28:00 GMT,表示等到这个绝对时刻。

两种都要处理,因为你没法保证每个上游都用同一种。日期格式还有个坑:它是绝对时间,如果客户端时钟和服务端有偏差,算出来的等待时长会不准,极端情况下甚至是负数。所以解析后要做钳制:

from email.utils import parsedate_to_datetime
from datetime import datetime, timezone

def parse_retry_after(resp, cap=60.0):
    raw = resp.headers.get("Retry-After")
    if not raw:
        return None
    raw = raw.strip()
    try:
        secs = float(raw)                       # 格式一:秒数
    except ValueError:
        try:
            when = parsedate_to_datetime(raw)   # 格式二:HTTP 日期
            if when.tzinfo is None:
                when = when.replace(tzinfo=timezone.utc)
            secs = (when - datetime.now(timezone.utc)).total_seconds()
        except (TypeError, ValueError):
            return None
    return max(0.0, min(secs, cap))             # 钳制到 [0, cap]

上面的 cap=60 同样是自设示例值。做钳制是为了防御两件事:一是时钟偏差导致的负数或畸形值,二是上游给出一个远超你业务容忍度的等待时长(比如让你等 300 秒)——这种时候更合理的做法不是傻等,而是直接失败并降级,把这次调用交给别的路径。

顺带一提,即使听了 Retry-After,也建议在它上面叠加一点点随机抖动(比如 ±10%)。因为所有被限流的客户端会拿到同一个 Retry-After 值,如果都精确地在那一刻重发,你又制造了一次惊群。

三道闸门:不设就一定失控

退避解决的是”隔多久试”,闸门解决的是”什么时候不再试”。缺了闸门的重试,迟早会以某种方式把你打疼。

闸门一:最大重试次数

最基础的一道,几乎人人都会设。但要注意它的语义要清晰:max_attempts=3 是”总共发 3 次请求”还是”首次失败后再试 3 次、总共 4 次”?这两种理解在同一个团队里同时存在是常事,接口文档里必须写死一种。

闸门二:总时间预算(比次数更重要)

这是最容易被漏掉、也最容易出事的一道。

回看上面那张表:三次重试听起来很克制,但如果每次退避都抽到接近上限的值,用户已经等了十几秒;六次重试在示例参数下最坏要等 61 秒。而调用方那边可能设了 30 秒的总超时——你在 61 秒的重试链条上耗着,上层早就把连接掐了,你后面几次重试的结果根本没人接收。这就是纯粹的浪费:浪费你的时间、浪费上游的容量、浪费真金白银的调用费。

所以正确的写法是先算预算,再决定要不要等:每次准备 sleep 之前,检查”已用时间 + 本次等待”是否会超出总预算,超了就直接放弃,别把时间花在一次注定来不及的重试上。上面那段示例代码里的 budget 参数就是干这个的。

时间预算应该从调用链的最外层往里传递。如果外层给了 30 秒,中间层花掉 5 秒,那么传给内层的预算就是 25 秒。这套做法在很多 RPC 框架里叫 deadline propagation,值得在自己的封装里也实现一份哪怕最简版本。

闸门三:熔断

前两道闸门管的是单个请求,熔断管的是整体流量。这是很多人分不清的地方——他们觉得”我都有重试上限了,还要熔断干嘛”。

区别在这里:假设上游彻底挂了,所有请求都失败。有重试上限意味着每个请求试 3 次就放弃,但如果你每秒有 1000 个新请求进来,那上游每秒还是要挨 3000 次打。重试上限限制的是”每个请求打几下”,它完全没有限制”一共有多少请求在打”。

熔断器(circuit breaker)做的是后者:统计最近一段窗口内的失败率或连续失败数,超过阈值就直接切断,之后的请求连发都不发,本地立即失败。 冷却一段时间后放少量试探请求过去(半开状态),成功就恢复,失败就继续断开。

三个状态:

状态行为转移条件
关闭(正常)请求正常放行,同时统计失败失败率或连续失败数超阈值 → 打开
打开(熔断)请求不发出,本地立即返回错误冷却时间到 → 半开
半开(试探)只放行少量请求试探成功 → 关闭;试探失败 → 重新打开

熔断和重试是互补的:重试处理偶发的、孤立的失败;熔断处理持续的、系统性的失败。两者的关系应该是”熔断在外、重试在内”——熔断器判断这个上游现在还能不能用,能用才进入重试逻辑;一旦熔断打开,连第一次请求都不该发。

熔断打开时你做什么,比熔断本身更重要。直接报错给用户是最差的选择。更好的是降级:切到备用上游、返回缓存结果、或者把任务丢进队列稍后处理。多上游切换的具体做法可以看 网关故障转移

一个实操提醒:熔断器的统计要按上游、按端点分别做,不要全局一个。某个上游的某个端点挂了,不应该导致所有调用全部熔断。粒度太粗的熔断器会把小故障放大成大故障。

重试的成本:每一次都是真金白银

这一节是给管钱的人看的,但工程师更该看。

一次失败的调用是否计费,取决于失败发生在哪个阶段:请求还没被受理就被拒(比如限流拦在网关),通常不产生费用;而如果请求已经被受理、模型已经开始生成,只是响应在回来的路上出了问题(读超时、连接中断),那么这次调用的成本很可能已经产生了——你没拿到结果,但对面已经算过了。具体规则各家不同,以官方计费文档为准,不要凭感觉假设。

所以在超时场景下的重试有个反直觉的性质:你可能为同一次生成付了两次钱,却只拿到一次结果。如果你的重试次数设得很大,最坏情况下一个请求能把成本翻好几倍。

把这件事变成可管理的,需要两个动作:

第一,把重试计数打成指标。 至少埋这几个:

  • llm_request_total:请求总数(含重试)
  • llm_request_attempts:每个逻辑调用实际发了几次(做成直方图)
  • llm_retry_total{reason}:按原因分类的重试次数(429 / 5xx / timeout 分开)
  • llm_retry_exhausted_total:重试耗尽最终失败的次数
  • llm_circuit_open_total:熔断打开的次数

第二,把重试率作为告警指标。 重试率 = 重试次数 / 逻辑调用数。这个值在正常状态下应该很低且稳定。它异常上涨是故障的先行信号——通常比错误率告警更早触发,因为重试成功的请求在最终错误率上是看不见的。你的成功率仪表盘一切正常,但重试率已经翻了三倍,说明上游已经在劣化,只是被你的重试逻辑暂时兜住了。这个时候介入,比等到用户投诉再查要从容得多。

重试率上涨还有一个更直接的后果:账单上涨。如果你在做单位成本核算,重试次数不计入的话,算出来的每千次调用成本会偏低,规模上去之后偏差会很明显。成本口径怎么建,可以参考 成本监控

另外,把 llm_request_attempts 做成直方图而不是计数器是有讲究的:你想知道的不是”总共重试了多少次”,而是”有多少比例的调用需要重试才成功”。前者是个受流量影响的绝对值,后者才是能反映健康度的分布。

幂等性:写操作重试的前提

前面反复提到副作用,这里收口。

先分清两种情况。纯生成调用本身没有副作用:你发一段提示词,模型返回一段文本,重复执行两次只是多花一次钱,不会造成数据错误。但你的业务链路整体几乎一定是有副作用的:拿到模型返回后,你要写数据库、要扣用户额度、要发一条消息、要更新任务状态。只要这条链路上有任何一个写操作,重试就必须考虑幂等。

幂等键(idempotency key)的做法是标准解:调用方为每个逻辑操作生成一个唯一 ID(UUID 即可),重试时沿用同一个 ID;服务端拿这个 ID 做去重,见过就直接返回上次的结果,不重复执行。

关键点有三个,每一个都容易写错:

  1. 幂等键必须在重试循环之外生成。 我见过在 for 循环里 uuid4() 的写法——每次重试都是新 ID,幂等机制完全失效,形同虚设。这是幂等相关最高频的 bug。
  2. 幂等键要有合理的存活期。 太短(比如几秒)覆盖不了长退避后的重试;太长会一直占存储。存活期应当大于你的最大重试时间预算,留出余量。
  3. 上游不支持幂等键时,自己在业务侧兜。 很多上游 API 并不提供幂等键参数。这时候要把幂等做在你自己的写入侧:给业务表加唯一约束(比如 (user_id, request_id) 唯一索引),重复写入直接被数据库挡掉;或者用”先记录意图、再执行、最后标记完成”的三段式,重试时先查状态。

一个具体场景:用户发起一次内容生成,你的服务调用模型,成功后扣 1 个额度并写入结果。如果读超时后你重试,而第一次其实已经成功了——没有幂等保护的话,用户被扣了 2 个额度,数据库里多了一条重复记录。这种事故的特点是平时不出现,一出现就是一批,因为触发它的前提是上游劣化,而上游劣化时会同时影响很多请求。

还有一个容易忽略的角度:并发也会破坏幂等。如果两个线程同时用同一个幂等键发请求(比如用户连点了两次按钮),去重逻辑如果只是”先查再写”,中间存在竞态窗口。稳妥的做法是依赖数据库的唯一约束来保证原子性,而不是应用层的 check-then-act。相关的并发控制思路可以看 并发控制

落地自查清单

写完或者 review 重试代码时,逐条对一遍:

  1. 错误分类了吗? 400/401/403/422 这类必须直接失败,不进重试循环。只有 429、5xx、网络错误、连接超时才进。
  2. 退避加抖动了吗? 只有指数退避没有抖动,等于把惊群从”每秒一次”变成”每几秒一次”,问题没解决。全抖动或等比抖动选一种,写进公共封装。
  3. 退避上限(cap)设了吗? 没有 cap 的指数退避会跑出小时级的等待。
  4. Retry-After 优先处理了吗? 两种格式都要解析,结果要钳制到合理区间,最好再叠一点抖动。
  5. 总时间预算有吗? 光有次数上限不够。每次 sleep 前判断”已用时间 + 本次等待”是否超预算,超了就提前放弃。预算要从调用链外层往里传。
  6. 熔断有吗?粒度对吗? 按上游、按端点分别统计,不要全局一个。熔断在外、重试在内,打开时要有降级路径而不是直接报错。
  7. 重试指标埋了吗? 至少埋重试次数(按原因分类)、每调用尝试次数分布、重试耗尽次数。重试率异常上涨要能触发告警。
  8. 写路径的幂等键,是在重试循环之外生成的吗? 循环里生成新 ID 是最常见的致命 bug。上游不支持幂等键的,用数据库唯一约束在自己这边兜住。

最后说一句关于起步的话。刚接一个新上游、还不清楚它的限速阈值和恢复特性时,最稳妥的姿势不是照抄别人的参数,而是:低并发起步,先观测再加压。 先用保守的退避参数(大基数、小重试次数、短时间预算)跑一批真实流量,把重试率、Retry-After 的实际取值、失败恢复所需时长这几个数据观测出来,再回头调参数。你自己跑出来的数字,比任何一篇文章给的推荐值都可靠——包括这一篇里的示例参数。

相关阅读