指数退避重试与超时控制:写给任何"失败后想重试"场景的第一道防线

网络请求失败、数据库连接闪断、第三方接口 5xx、消息队列抖动……只要你的程序会调用别人,就逃不开两个问题:“要不要重试” 和 “最多等多久”。最简单的”立刻重试”往往变成灾难:所有失败请求在同一秒集体撞回去,把服务端打得更死;而完全不设超时的调用,会在对方挂掉时把你的线程/协程无限期挂住。本文用两段不到 100 行的 Python 代码,讲透”指数退避重试”与”超时控制”这对黄金搭档——为什么退避比固定间隔聪明,超时该怎么兜底,怎么组合使用,以及一个容易被忽略的坑:重试让用户等更久,还可能把网关(nginx)等到超时。

一、先打个比方:什么是”退避”?

想象你在打电话给一个很忙的客服:

  • 固定间隔重试:打不通 → 等 1 秒再打 → 还不通 → 再等 1 秒……结果 100 个用户全都在”等 1 秒”,到点后集体重拨,客服电话再次被打爆。
  • 指数退避重试:第 1 次打不通 → 等 0.5 秒;第 2 次还不通 → 等 1 秒;第 3 次 → 等 2 秒……每个人等待时间自然错开,客服那边的压力慢慢消化。

代码里的注释说得很直白:

1
2
3
固定 1 秒重试 = 所有失败请求在同一秒集体撞回去,把服务端打得更死(惊群)。
指数退避(0.5s → 1s → 2s)让流量自然散开,
再加 jitter(±随机)避免残余的同步。

“惊群”:一群等待的请求同时醒来、同时发出去,像羊群一样撞向服务器——这是分布式系统里非常经典的坑。

指数退避不是某个场景的专利,它是一类通用的可靠性模式:爬虫、支付回调、微服务调用、消息消费、LLM API……只要”失败是暂时的、操作是幂等的”,它都适用。

二、什么时候该重试?什么时候不该?

不是所有错误都值得重试。我们把异常分成两类:

错误类型 例子 该重试吗 原因
临时性错误 超时、连接失败、429 限流、502/503 ✅ 该 过一会儿可能就好了
永久性错误 400 参数错误、401 鉴权失败、业务逻辑错误 ❌ 不该 重试一万次结果也一样,纯浪费

代码里用一个元组声明”我认为可重试的异常”:

1
2
#: 默认认为可重试的异常。注意:不包含业务错误和 4xx 参数错误
RETRYABLE: tuple[type[BaseException], ...] = (TimeoutError, ConnectionError)

默认只认 TimeoutError(超时)和 ConnectionError(连接失败)。调用第三方 HTTP 接口时,你通常还会把 429、5xx 对应的异常类加进去。

还有一个大前提:操作必须是幂等的(重试一次和执行一次效果相同)。”查询”随便重试;”扣款”不加幂等键就重试,可能变扣两次。非幂等操作要么先做幂等设计,要么干脆不重试。

三、超时控制:重试的前提

重试的前提是”失败要能尽快被发现”。如果一次调用没有超时上限,对方挂了你的协程就永远 await 在那里——根本走不到重试逻辑。所以超时是重试的黄金搭档。

超时代码的注释一句话点题:

1
超时控制 —— LLM 是远程服务,慢是常态,必须有兜底的等待上限。

这里的 LLM 只是例子,换成任何远程服务(第三方 API、数据库、内网 RPC)都成立:慢是常态,必须有兜底的等待上限。

3.1 完整实现:with_timeout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LLMTimeoutError(TimeoutError):
"""统一的超时异常:上层只需要捕获这一个类型。"""


async def with_timeout(
factory: Callable[[], Awaitable[T]],
*,
timeout: float,
) -> T:
"""在 timeout 秒内跑完 factory() 返回的协程,超时抛 LLMTimeoutError。

factory 是「零参协程工厂」而不是协程对象本身,这样每次调用
都能造出全新的协程,方便重试逻辑反复执行。
"""
if timeout <= 0:
raise ValueError(f"timeout 必须 > 0,收到 {timeout}")
try:
return await asyncio.wait_for(factory(), timeout)
except TimeoutError as exc:
raise LLMTimeoutError(f"调用超过 {timeout:g}s 未完成") from exc

3.2 参数说明

参数 作用
factory 零参协程工厂,比如 lambda: chat_async(prompt)。不是协程对象本身
timeout 等待上限(秒)。小于等于 0 直接抛 ValueError

3.3 为什么参数是”协程工厂”而不是”协程对象”?

这是最容易踩的坑,也是代码注释里重点强调的:

1
2
3
传 factory() 而不是 await factory() ——
先 await 会把调用整个跑完再计时,
超时保护形同虚设,超时后底层任务也不会被取消。

对比一下:

1
2
3
4
5
6
7
8
# ❌ 错误:协程对象只造了一次,重试时复用同一个,行为未定义
coro = openai_chat(prompt)
result = await with_timeout(coro, timeout=10)
retry_result = await with_timeout(coro, timeout=10) # 危险!

# ✅ 正确:每次传工厂,with_timeout 内部 factory() 造出全新协程
result = await with_timeout(lambda: openai_chat(prompt), timeout=10)
retry_result = await with_timeout(lambda: openai_chat(prompt), timeout=10)

类比:工厂每次开模造新零件;你要是把一个零件递进去,第一次用完就废了,不能指望它还能造第二次。

3.4 两个经典踩坑点(原实现都踩过)

坑 1:忘了 await

1
2
3
4
5
6
# ❌ asyncio.wait_for 本身就是协程,不 await 等于什么都没执行
asyncio.wait_for(factory(), timeout)
# 会得到 "coroutine wait_for was never awaited" 警告,然后返回一个协程对象

# ✅
return await asyncio.wait_for(factory(), timeout)

坑 2:传了 await factory() 而不是 factory()

1
2
3
4
5
6
# ❌ 先 await 会把调用「整个跑完」再计时
return await asyncio.wait_for(await factory(), timeout)
# 超时保护形同虚设;超时后底层任务也不会被取消(资源泄漏)

# ✅ 把「还没开始跑的协程」交给 wait_for,由它计时并管理取消
return await asyncio.wait_for(factory(), timeout)

3.5 为什么自定义 LLMTimeoutError?

1
2
except TimeoutError as exc:
raise LLMTimeoutError(f"调用超过 {timeout:g}s 未完成") from exc

三个好处:

  1. 上层只需 catch 一个类型:业务代码 except LLMTimeoutError 即可,不用区分”是 wait_for 超时”还是”底层 HTTP 客户端超时”。
  2. 保留 is-a 关系:LLMTimeoutError 继承 TimeoutError,所以 with_retry 里 isinstance(exc, (TimeoutError, ...)) 依然成立——超时自动变成”可重试错误”,两个模块无缝协作。
  3. from exc 保留因果链:traceback 里能看到”是 wait_for 超时导致的”,排查时不抓瞎。

3.6 超时方案怎么选?

with_timeout 基于 asyncio.wait_for,适合 asyncio 异步代码。同步代码里常见的超时方案:

方案 适用场景 说明
requests/httpx 自带 timeout= HTTP 调用 最常用,连接超时和读超时分开设:timeout=(3, 30)
asyncio.wait_for / asyncio.timeout asyncio 协程 本文 with_timeout 的底层;3.11+ 也可用 async with asyncio.timeout(n)
concurrent.futures + future.result(timeout=) 线程池 把阻塞调用丢线程池,主线程限时等结果
数据库/驱动自带参数 DB 驱动 如 MySQL read_timeout、Redis socket_timeout
信号/SIGALRM 纯同步老代码 signal.alarm 只能在主线程用,局限大,新项目不推荐

原则:优先用依赖项自带的超时参数(更精准、能中断底层 IO);没有现成参数时,再用 with_timeout 这类”从外面掐表”的包裹方案。

四、核心公式:睡多久才合适?

退避时长由一个简单公式决定:

1
delay = min(base_delay × 2^attempt, max_delay) × random(1-jitter, 1+jitter)

拆开看就三步:

  1. 指数放大:base_delay × 2^attempt → 0.5 → 1 → 2 → 4 …
  2. 封顶:min(..., max_delay) → 最多睡到 8 秒,不让等待无限膨胀
  3. 加抖动:乘一个 (1-jitter, 1+jitter) 之间的随机数 → ±10% 随机

对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def compute_delay(
attempt: int,
*,
base_delay: float,
max_delay: float,
jitter: float,
) -> float:
"""第 attempt 次失败后应该睡多久(attempt 从 0 开始计)。

公式:delay = min(base_delay * 2**attempt, max_delay) * 随机(1-jitter, 1+jitter)
上限 max_delay 防止退避时间无限膨胀(封顶的是指数项,实际等待最多到 max_delay*(1+jitter))。
"""
exponential = min(base_delay * 2 ** attempt, max_delay)
delay = exponential * random.uniform(1 - jitter, 1 + jitter)
return delay

为什么封顶之后还要加 jitter?

假设 max_delay=8,没有 jitter 时,所有重试者封顶后都精确睡 8 秒——到点又是一次”集体撞回去”。加上 ±10% 抖动后,大家分别睡 7.2~8.8 秒不等,重试流量被自然打散。

默认参数下的等待时间示意(base_delay=0.5, max_delay=8, jitter=0.1):

第几次失败 (attempt) 指数部分 加抖动后实际范围
0 0.5s 0.45 ~ 0.55s
1 1s 0.9 ~ 1.1s
2 2s 1.8 ~ 2.2s
3 4s 3.6 ~ 4.4s
4 8s(封顶) 7.2 ~ 8.8s
5+ 8s(封顶) 7.2 ~ 8.8s

注意最坏情况:max_attempts=3 时总等待约 0.5 + 1 = 1.5 秒;max_attempts=5 时约 0.5 + 1 + 2 + 4 = 7.5 秒。重试次数越多,用户和网关等得越久——这个账在同步接口里要算清楚,后文会专门讲。

五、完整实现:with_retry

把”执行 → 失败判断 → 睡觉 → 重试”串起来,就是完整的重试工具(高阶函数形式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def with_retry(
fn: Callable[[], T],
*,
max_attempts: int = 3,
base_delay: float = 0.5,
max_delay: float = 8.0,
jitter: float = 0.1,
retry_on: Sequence[type[BaseException]] = RETRYABLE,
sleep: Callable[[float], None] = time.sleep,
) -> T:
"""反复执行无参函数 fn,直到成功或次数用尽。

第一次立即执行,失败后按 compute_delay 睡觉再试。
测试通过注入 sleep=lambda s: recorded.append(s) 来避免真的等待。
"""
if max_attempts < 1:
raise ValueError(f"max_attempts 必须 >= 1,收到 {max_attempts}")

for attempt in range(max_attempts):
try:
return fn()
except Exception as exc:
if not isinstance(exc, retry_on):
raise
if attempt == max_attempts - 1:
raise
sleep(compute_delay(attempt, base_delay=base_delay,
max_delay=max_delay, jitter=jitter))

参数说明

参数 默认值 作用
fn 必填 要被执行的无参函数(包一层要重试的调用)
max_attempts 3 最多尝试几次(含第一次)。小于 1 直接抛 ValueError
base_delay 0.5 第一次失败后睡多久(秒),后面每次翻倍
max_delay 8.0 单次等待的上限,防止退避无限膨胀
jitter 0.1 抖动比例,±10%,把同步重试打散
retry_on (TimeoutError, ConnectionError) 哪些异常值得重试
sleep time.sleep 睡觉函数,测试时可注入假的,避免真的等

执行流程图

graph TD
    A[开始:调用 with_retry] --> B{max_attempts >= 1?}
    B -- 否 --> C[抛 ValueError]
    B -- 是 --> D[调用 fn]
    D -- 成功 --> E[原样返回结果]
    D -- 失败 --> F{异常类型在 retry_on 中?}
    F -- 否 --> G[立即原样抛出<br/>不再调用 fn]
    F -- 是 --> H{还有下一次机会?}
    H -- 否 --> I[抛出最后一次异常]
    H -- 是 --> J[按 compute_delay 睡觉]
    J --> D

六、代码里的 6 条”契约”

函数 docstring 里列了必须满足的契约,每一条都对应一个容易写错的细节:

  1. fn 成功 → 原样返回结果,不 sleep
    成功了还睡觉?那是白白拖慢正常请求。

  2. 异常类型在 retry_on 里 → sleep 该睡的秒数后重试

  3. 异常类型不在 retry_on 里 → 立即原样抛出,不再调用 fn
    参数错误重试也没用,别浪费配额。

  4. 次数用尽 → 抛出最后一次的异常
    用裸 raise 原样重抛,traceback 干净;写 raise exc 会在堆栈尾部多一帧噪音。

  5. max_attempts < 1 → 抛 ValueError
    参数非法 fail fast,别等到发了请求才发现配置错了。

  6. 至少调用 fn 一次
    max_attempts=1 时只试一次,失败就抛。

另一个容易忽略的细节:

1
if not isinstance(exc, retry_on):

用 isinstance 而不是 type(exc) in retry_on——要求重试 TimeoutError 时,它的子类(比如前面定义的 LLMTimeoutError)也必须能被重试。这正是超时模块和重试模块能协作的关键:LLMTimeoutError 是 TimeoutError 的子类,with_retry 自动把它当可重试错误处理。

七、组合使用:with_timeout + with_retry

两层各司其职:

1
2
3
with_retry(外层:失败了别放弃,退避后再试)
└── with_timeout(内层:每次尝试最多等 N 秒,到点果断取消)
└── 真正的远程调用

同步场景:依赖项自带 timeout

1
2
3
4
result = with_retry(
lambda: requests.get("https://api.example.com/data", timeout=(3, 10)),
max_attempts=3,
)

requests 的 timeout 负责单次掐表,with_retry 负责失败后退避重试,无需 with_timeout。

异步场景:用 with_timeout 包住协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async def ask_llm(prompt: str) -> str:
async def factory():
return await openai_chat(prompt) # 零参协程工厂
return await with_timeout(factory, timeout=15) # 单次最多 15s


async def ask_llm_with_retry(prompt: str) -> str:
for attempt in range(3):
try:
return await ask_llm(prompt) # 超时会抛 LLMTimeoutError
except LLMTimeoutError: # 是 TimeoutError 子类,可重试
if attempt == 2:
raise
await asyncio.sleep(compute_delay(
attempt, base_delay=0.5, max_delay=8.0, jitter=0.1))

组合后的行为:每次尝试最多等 15 秒;超时抛 LLMTimeoutError → 被识别为可重试 → 按指数退避睡一觉 → 工厂造出全新协程再来。注意每次重试都传新的 lambda: ...,这就是”协程工厂”存在的意义。

测试时同样可以注入

超时层测试可以传一个”故意睡很久”的工厂,把 timeout 设成 0.01 秒验证会抛 LLMTimeoutError;重试层测试注入假 sleep。两层都能在毫秒级跑完单测。

八、使用示例

示例 1:最简单的用法

1
2
result = with_retry(lambda: requests.get("https://api.example.com/data", timeout=5))
print(result.status_code)

默认最多试 3 次,失败后约等 0.5s、1s 再试。

示例 2:调第三方 HTTP 接口,自定义重试策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class RateLimitError(Exception):
"""429 限流,默认不在 RETRYABLE 里,需要显式加上"""
pass

class ServerError(Exception):
"""5xx 服务端错误"""
pass

resp = with_retry(
lambda: call_payment_gateway(order_id),
max_attempts=4, # 最多试 4 次
base_delay=0.5, # 第一次失败睡 0.5s
max_delay=8.0, # 最长睡 8s
jitter=0.2, # ±20% 抖动,打散效果更强
retry_on=(TimeoutError, ConnectionError, RateLimitError, ServerError),
)

示例 3:测试时注入假 sleep,避免真的等待

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_retry_sleeps_with_backoff():
recorded = []
calls = {"n": 0}

def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise TimeoutError("模拟超时")
return "成功"

result = with_retry(flaky, sleep=recorded.append)

assert result == "成功"
assert calls["n"] == 3 # 调用了 3 次
assert len(recorded) == 2 # 睡了 2 次(最后一次成功前不用睡)
# recorded[0] 约 0.45~0.55,recorded[1] 约 0.9~1.1

sleep=recorded.append 这个技巧很实用:把”睡觉”这件事变成可注入的依赖,单元测试里毫秒级就能跑完,不用真的 time.sleep 等几秒。

九、典型使用场景

场景 适合重试吗 超时怎么设 说明
爬虫抓取 ✅ 单请求 5~15s 偶发 503、连接重置,退避重试比”失败就放弃”成功率高
微服务间调用 ✅ 比下游 P99 略大 下游重启/过载的几秒抖动,退避重试能扛过去
数据库/缓存连接 ✅ 驱动自带参数 连接池耗尽、网络闪断,重试 + 退避比直接报错强
消息队列消费 ✅ 消费逻辑限时 消费失败重新入队,配合退避避免毒消息反复撞垮消费者
支付、发短信 ⚠️ 宁长勿短 必须先做幂等(同一订单不会重复扣款)
LLM API 调用 ⚠️ 单次 10~30s 适合后台异步任务;同步给用户的接口要慎用长重试,见下一节

指数退避和超时都是通用模式,但”要不要用、敢不敢多试几次”,取决于谁在等这个结果。后台任务可以慢慢退避;用户正盯着的界面,每多睡一秒都是体验损耗。

十、坑:重试让用户等更久,还可能把 nginx 等到超时

这一点在 LLM 调用等慢接口上格外突出:大模型一次生成动辄几秒到几十秒,本身就容易超时;一旦叠加退避重试,问题会放大。

10.1 用户在屏幕前干等

同步接口里,重试的每一秒 sleep 都直接算进用户的响应时间:

1
2
3
4
最坏情况总耗时 ≈ 每次调用超时时间 × 失败次数 + 各次 sleep 之和

例:单次超时 10s,max_attempts=3,base_delay=0.5
最坏 ≈ 10 + 0.5 + 10 + 1 + 10 ≈ 31.5 秒

用户盯着 loading 转了半分钟,体验极差——还不如第一次失败就快速给出”请稍后重试”的友好提示。

10.2 nginx 先于你的重试结束而 504

更隐蔽的是网关超时。典型链路:

1
用户 → nginx(proxy_read_timeout 默认 60s) → 你的后端 → LLM API

你的重试逻辑还在第 2 次 sleep 中,nginx 已经等不及返回 504 Gateway Timeout 了。此时:

  • 用户看到的是网关错误,不是你的友好降级文案;
  • 你的后端还在继续调 LLM,算力和 token 照烧;
  • 用户可能已刷新页面重发请求,流量进一步放大。

经验法则:你的”重试预算”必须明显小于上游网关的超时时间,比如 nginx 60s,应用层整个 with_retry(含所有 sleep 和每次调用的 timeout)尽量控制在 20~30s 内,留足余量。

10.3 那同步接口该怎么用重试?

  1. 压缩预算:max_attempts 降到 2~3,max_delay 调小(比如 2s),单次调用的 timeout 也设短——宁可快速失败,不要拖死网关。
  2. 重试放后台:同步接口收到 429/5xx 后立即返回”任务已接收/请稍后查询”,真正的指数退避重试交给后台 worker(Celery、任务队列)。LLM 生成类请求本来就更适合异步任务 + 轮询/WebSocket/SSE 推送结果。
  3. 快速降级:重试耗尽前就准备好兜底——缓存的旧结果、简化模型、”稍后再试”文案,比裸抛 504 体验好得多。
  4. 流式输出减耐心成本:LLM 场景用 SSE/streaming,首 token 尽快吐出来,用户感知等待会短很多(但注意:流式一旦开始输出就不能再重试整次调用了,重试窗口只在首 token 之前)。
  5. 区分调用方:机器调机器(服务间)可以多试几次;人肉终端用户在等的接口,次数要吝啬。

一句话:超时解决”别无限等”,指数退避解决”别把对方打死”,两者都不解决”让用户等太久”。后者要么砍预算,要么把等待挪出同步链路。

十一、常见误区

  1. 对 4xx 参数错误重试 —— 参数错了重试一万次还是错,还白扣配额。
  2. 不设上限次数,死循环重试 —— 服务真挂了会无限重试,拖垮自己。max_attempts 必须有。
  3. 封顶后忘了 jitter —— 所有人睡一样久,到点又是一波惊群。
  4. 对非幂等操作盲目重试 —— “创建订单”重试可能变两笔订单。先加幂等键,再谈重试。
  5. 远程调用不设超时 —— 对方挂了你的协程/线程永久挂起,连重试的机会都没有。
  6. 重试预算 ≥ 网关超时 —— 后端还在睡,nginx 已 504。应用层总预算要给网关留余量。
  7. 重试代替了监控 —— 重试是第一道防线,不是唯一防线。连续失败要报警,让人来看。

十二、总结

  • 超时是重试的前提:没有等待上限,失败无法被及时发现;with_timeout 用 asyncio.wait_for 兜底,到点抛统一的 LLMTimeoutError。
  • 协程工厂而非协程对象:factory() 每次造新协程,重试才能反复执行;先 await 再传会让超时形同虚设。
  • 固定间隔重试会制造惊群,指数退避 + jitter 让失败流量自然散开——这是通用模式,爬虫、微服务、支付、LLM 都能用。
  • 只重试”暂时性错误”(超时、连接失败、429、5xx),永久性错误立即抛出;非幂等操作先做幂等再重试。
  • 封顶防膨胀,抖动防同步,两个机制缺一不可。
  • 给用户等的同步接口,慎用长重试:压缩 max_attempts 和 max_delay,确保重试总预算远小于 nginx 等网关的超时;更优解是后台异步重试 + 快速降级。

下次再看到接口报 429 或 5xx,别慌——先用超时掐住单次等待,再用指数退避优雅地退一步,同时算好”用户和网关最多愿意等多久”这笔账。

参考资料

  • AWS Architecture Blog - Exponential Backoff And Jitter
  • Google Cloud - Handling API errors & retries
  • Stripe - Retries & idempotency
  • NGINX Docs - proxy_read_timeout
  • Python Docs - asyncio.wait_for