指数退避重试与超时控制:写给任何"失败后想重试"场景的第一道防线
网络请求失败、数据库连接闪断、第三方接口 5xx、消息队列抖动……只要你的程序会调用别人,就逃不开两个问题:“要不要重试” 和 “最多等多久”。最简单的”立刻重试”往往变成灾难:所有失败请求在同一秒集体撞回去,把服务端打得更死;而完全不设超时的调用,会在对方挂掉时把你的线程/协程无限期挂住。本文用两段不到 100 行的 Python 代码,讲透”指数退避重试”与”超时控制”这对黄金搭档——为什么退避比固定间隔聪明,超时该怎么兜底,怎么组合使用,以及一个容易被忽略的坑:重试让用户等更久,还可能把网关(nginx)等到超时。
一、先打个比方:什么是”退避”?
想象你在打电话给一个很忙的客服:
- 固定间隔重试:打不通 → 等 1 秒再打 → 还不通 → 再等 1 秒……结果 100 个用户全都在”等 1 秒”,到点后集体重拨,客服电话再次被打爆。
- 指数退避重试:第 1 次打不通 → 等 0.5 秒;第 2 次还不通 → 等 1 秒;第 3 次 → 等 2 秒……每个人等待时间自然错开,客服那边的压力慢慢消化。
代码里的注释说得很直白:
1 | 固定 1 秒重试 = 所有失败请求在同一秒集体撞回去,把服务端打得更死(惊群)。 |
“惊群”:一群等待的请求同时醒来、同时发出去,像羊群一样撞向服务器——这是分布式系统里非常经典的坑。
指数退避不是某个场景的专利,它是一类通用的可靠性模式:爬虫、支付回调、微服务调用、消息消费、LLM API……只要”失败是暂时的、操作是幂等的”,它都适用。
二、什么时候该重试?什么时候不该?
不是所有错误都值得重试。我们把异常分成两类:
| 错误类型 | 例子 | 该重试吗 | 原因 |
|---|---|---|---|
| 临时性错误 | 超时、连接失败、429 限流、502/503 | ✅ 该 | 过一会儿可能就好了 |
| 永久性错误 | 400 参数错误、401 鉴权失败、业务逻辑错误 | ❌ 不该 | 重试一万次结果也一样,纯浪费 |
代码里用一个元组声明”我认为可重试的异常”:
1 | #: 默认认为可重试的异常。注意:不包含业务错误和 4xx 参数错误 |
默认只认 TimeoutError(超时)和 ConnectionError(连接失败)。调用第三方 HTTP 接口时,你通常还会把 429、5xx 对应的异常类加进去。
还有一个大前提:操作必须是幂等的(重试一次和执行一次效果相同)。”查询”随便重试;”扣款”不加幂等键就重试,可能变扣两次。非幂等操作要么先做幂等设计,要么干脆不重试。
三、超时控制:重试的前提
重试的前提是”失败要能尽快被发现”。如果一次调用没有超时上限,对方挂了你的协程就永远 await 在那里——根本走不到重试逻辑。所以超时是重试的黄金搭档。
超时代码的注释一句话点题:
1 | 超时控制 —— LLM 是远程服务,慢是常态,必须有兜底的等待上限。 |
这里的 LLM 只是例子,换成任何远程服务(第三方 API、数据库、内网 RPC)都成立:慢是常态,必须有兜底的等待上限。
3.1 完整实现:with_timeout
1 | class LLMTimeoutError(TimeoutError): |
3.2 参数说明
| 参数 | 作用 |
|---|---|
factory |
零参协程工厂,比如 lambda: chat_async(prompt)。不是协程对象本身 |
timeout |
等待上限(秒)。小于等于 0 直接抛 ValueError |
3.3 为什么参数是”协程工厂”而不是”协程对象”?
这是最容易踩的坑,也是代码注释里重点强调的:
1 | 传 factory() 而不是 await factory() —— |
对比一下:
1 | # ❌ 错误:协程对象只造了一次,重试时复用同一个,行为未定义 |
类比:工厂每次开模造新零件;你要是把一个零件递进去,第一次用完就废了,不能指望它还能造第二次。
3.4 两个经典踩坑点(原实现都踩过)
坑 1:忘了 await
1 | # ❌ asyncio.wait_for 本身就是协程,不 await 等于什么都没执行 |
坑 2:传了 await factory() 而不是 factory()
1 | # ❌ 先 await 会把调用「整个跑完」再计时 |
3.5 为什么自定义 LLMTimeoutError?
1 | except TimeoutError as exc: |
三个好处:
- 上层只需 catch 一个类型:业务代码
except LLMTimeoutError即可,不用区分”是 wait_for 超时”还是”底层 HTTP 客户端超时”。 - 保留 is-a 关系:
LLMTimeoutError继承TimeoutError,所以with_retry里isinstance(exc, (TimeoutError, ...))依然成立——超时自动变成”可重试错误”,两个模块无缝协作。 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) |
拆开看就三步:
- 指数放大:
base_delay × 2^attempt→ 0.5 → 1 → 2 → 4 … - 封顶:
min(..., max_delay)→ 最多睡到 8 秒,不让等待无限膨胀 - 加抖动:乘一个
(1-jitter, 1+jitter)之间的随机数 → ±10% 随机
对应代码:
1 | def compute_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 | def with_retry( |
参数说明
| 参数 | 默认值 | 作用 |
|---|---|---|
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 里列了必须满足的契约,每一条都对应一个容易写错的细节:
fn 成功 → 原样返回结果,不 sleep
成功了还睡觉?那是白白拖慢正常请求。异常类型在 retry_on 里 → sleep 该睡的秒数后重试
异常类型不在 retry_on 里 → 立即原样抛出,不再调用 fn
参数错误重试也没用,别浪费配额。次数用尽 → 抛出最后一次的异常
用裸raise原样重抛,traceback 干净;写raise exc会在堆栈尾部多一帧噪音。max_attempts < 1 → 抛 ValueError
参数非法 fail fast,别等到发了请求才发现配置错了。至少调用 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 | with_retry(外层:失败了别放弃,退避后再试) |
同步场景:依赖项自带 timeout
1 | result = with_retry( |
requests 的 timeout 负责单次掐表,with_retry 负责失败后退避重试,无需 with_timeout。
异步场景:用 with_timeout 包住协程
1 | async def ask_llm(prompt: str) -> str: |
组合后的行为:每次尝试最多等 15 秒;超时抛 LLMTimeoutError → 被识别为可重试 → 按指数退避睡一觉 → 工厂造出全新协程再来。注意每次重试都传新的 lambda: ...,这就是”协程工厂”存在的意义。
测试时同样可以注入
超时层测试可以传一个”故意睡很久”的工厂,把 timeout 设成 0.01 秒验证会抛 LLMTimeoutError;重试层测试注入假 sleep。两层都能在毫秒级跑完单测。
八、使用示例
示例 1:最简单的用法
1 | result = with_retry(lambda: requests.get("https://api.example.com/data", timeout=5)) |
默认最多试 3 次,失败后约等 0.5s、1s 再试。
示例 2:调第三方 HTTP 接口,自定义重试策略
1 | class RateLimitError(Exception): |
示例 3:测试时注入假 sleep,避免真的等待
1 | def test_retry_sleeps_with_backoff(): |
sleep=recorded.append 这个技巧很实用:把”睡觉”这件事变成可注入的依赖,单元测试里毫秒级就能跑完,不用真的 time.sleep 等几秒。
九、典型使用场景
| 场景 | 适合重试吗 | 超时怎么设 | 说明 |
|---|---|---|---|
| 爬虫抓取 | ✅ | 单请求 5~15s | 偶发 503、连接重置,退避重试比”失败就放弃”成功率高 |
| 微服务间调用 | ✅ | 比下游 P99 略大 | 下游重启/过载的几秒抖动,退避重试能扛过去 |
| 数据库/缓存连接 | ✅ | 驱动自带参数 | 连接池耗尽、网络闪断,重试 + 退避比直接报错强 |
| 消息队列消费 | ✅ | 消费逻辑限时 | 消费失败重新入队,配合退避避免毒消息反复撞垮消费者 |
| 支付、发短信 | ⚠️ | 宁长勿短 | 必须先做幂等(同一订单不会重复扣款) |
| LLM API 调用 | ⚠️ | 单次 10~30s | 适合后台异步任务;同步给用户的接口要慎用长重试,见下一节 |
指数退避和超时都是通用模式,但”要不要用、敢不敢多试几次”,取决于谁在等这个结果。后台任务可以慢慢退避;用户正盯着的界面,每多睡一秒都是体验损耗。
十、坑:重试让用户等更久,还可能把 nginx 等到超时
这一点在 LLM 调用等慢接口上格外突出:大模型一次生成动辄几秒到几十秒,本身就容易超时;一旦叠加退避重试,问题会放大。
10.1 用户在屏幕前干等
同步接口里,重试的每一秒 sleep 都直接算进用户的响应时间:
1 | 最坏情况总耗时 ≈ 每次调用超时时间 × 失败次数 + 各次 sleep 之和 |
用户盯着 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 那同步接口该怎么用重试?
- 压缩预算:
max_attempts降到 2~3,max_delay调小(比如 2s),单次调用的timeout也设短——宁可快速失败,不要拖死网关。 - 重试放后台:同步接口收到 429/5xx 后立即返回”任务已接收/请稍后查询”,真正的指数退避重试交给后台 worker(Celery、任务队列)。LLM 生成类请求本来就更适合异步任务 + 轮询/WebSocket/SSE 推送结果。
- 快速降级:重试耗尽前就准备好兜底——缓存的旧结果、简化模型、”稍后再试”文案,比裸抛 504 体验好得多。
- 流式输出减耐心成本:LLM 场景用 SSE/streaming,首 token 尽快吐出来,用户感知等待会短很多(但注意:流式一旦开始输出就不能再重试整次调用了,重试窗口只在首 token 之前)。
- 区分调用方:机器调机器(服务间)可以多试几次;人肉终端用户在等的接口,次数要吝啬。
一句话:超时解决”别无限等”,指数退避解决”别把对方打死”,两者都不解决”让用户等太久”。后者要么砍预算,要么把等待挪出同步链路。
十一、常见误区
- 对 4xx 参数错误重试 —— 参数错了重试一万次还是错,还白扣配额。
- 不设上限次数,死循环重试 —— 服务真挂了会无限重试,拖垮自己。
max_attempts必须有。 - 封顶后忘了 jitter —— 所有人睡一样久,到点又是一波惊群。
- 对非幂等操作盲目重试 —— “创建订单”重试可能变两笔订单。先加幂等键,再谈重试。
- 远程调用不设超时 —— 对方挂了你的协程/线程永久挂起,连重试的机会都没有。
- 重试预算 ≥ 网关超时 —— 后端还在睡,nginx 已 504。应用层总预算要给网关留余量。
- 重试代替了监控 —— 重试是第一道防线,不是唯一防线。连续失败要报警,让人来看。
十二、总结
- 超时是重试的前提:没有等待上限,失败无法被及时发现;
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