Python 写一个可复用的重试装饰器:从 requests 超时到指数退避
调用一次接口失败就直接报错,是很多线上事故的起点。网络抖动、对方服务短暂重启、网关超时,这些问题往往重试一次就好了,但如果把重试逻辑散落在每个函数里,很快就会出现有的地方无限重试、有的地方把 400 错误也重试、有的地方重试时没有退避反而把对方打垮的情况。本文用一个可复用的 Python 装饰器把这些规则收敛到一处,配合 requests 实测可用的写法讲清楚。
先定三条规则,再写代码
第一,只重试值得重试的错误。连接超时、连接中断、502、503、504 这类错误通常是暂时的,值得重试;400、401、403、404 这类错误是请求本身或权限有问题,重试一百次结果也一样,只会浪费时间和配额。第二,重试必须有上限和退避。没有上限的重试会把一次短暂故障放大成雪崩,没有退避的重试会在对方刚恢复时又瞬间压垮它。第三,重试过程要可观测,至少记录第几次重试、等了多久、最后一次失败的原因,否则线上排障只能靠猜。
一个够用的装饰器
下面的实现只依赖标准库,参数都可以在调用处覆盖。重试次数默认 3 次,第一次等待 0.5 秒,之后按 2 的指数增长,并加一点随机抖动,避免所有实例在同一时刻同时重试。注意要用 functools.wraps 保留原函数的名称和文档,否则日志和调试信息会变得难以追踪。
import functools
import random
import time
def retry(times=3, base_delay=0.5, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
delay = base_delay
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
if attempt == times:
raise
print(f"第 {attempt} 次失败:{exc},{delay:.2f} 秒后重试")
time.sleep(delay + random.uniform(0, 0.2))
delay *= 2
return wrapper
return decorator
这里把异常类型做成参数非常关键。默认捕获 Exception 只是演示,生产代码一定要收窄,例如只传 requests.exceptions.Timeout 和 ConnectionError,再加一个专门处理状态码的分支。
结合 requests:超时和状态码分开处理
requests 有两个容易踩的坑。第一个是不传 timeout,默认会一直等下去,重试逻辑再完善也救不了已经卡住的请求。第二个是只看异常不看状态码:requests 默认不会因为 500 就抛异常,必须主动判断。下面的写法把两者分开,超时和连接错误走装饰器重试,业务状态码由调用方决定是否重试。
import requests
from requests.exceptions import ConnectionError, Timeout
@retry(times=3, exceptions=(Timeout, ConnectionError))
def fetch_user(user_id):
resp = requests.get(
f"https://api.example.com/users/{user_id}",
timeout=(3, 10),
)
if resp.status_code in (502, 503, 504):
raise RuntimeError(f"服务端暂不可用:{resp.status_code}")
resp.raise_for_status()
return resp.json()
timeout 写成 (3, 10) 表示连接超时 3 秒、读取超时 10 秒,比只写一个数字更贴合实际:连接阶段应该快,读取阶段可以给对方留出处理时间。
指数退避为什么不是越慢越好
指数退避的公式是 delay = base_delay * 2 ** (attempt - 1),再加随机抖动。第一次等 0.5 秒,第二次 1 秒,第三次 2 秒,总等待时间可控,也给了对方恢复的窗口。如果把 base_delay 设得过大,用户端的整体耗时会直线上升;如果设得过小,又起不到削峰作用。我的经验是:面向用户的同步接口,总重试等待时间不要超过 3 到 5 秒;后台任务可以放宽到 30 秒以上,但要设置总超时和幂等保护。
幂等是重试的前提
重试只对幂等操作安全。查询、下单查询、状态同步这类读操作可以放心重试;创建订单、扣款、发短信这类写操作如果不带幂等键,重试可能导致重复执行。正确的做法是调用方生成一个幂等键,例如用业务单号加请求标识,服务端用它去重;没有幂等保障的写接口,宁可直接失败报警,也不要自动重试。
常见错误清单
一是把所有异常都重试,包括参数错误和鉴权失败,结果把一次配置错误放大成大量无效请求。二是重试时不重置请求体,流式上传或文件对象读到末尾后第二次发送的是空内容。三是在循环里重试时没有区分第一次失败和后续失败,导致日志里看不到完整的失败链。四是把重试装饰器套在已经有重试逻辑的客户端上,例如 requests 的 HTTPAdapter 本身就带 max_retries,两层重试叠加后实际次数会远超预期。
一个可直接复用的检查表
上线前按这五项自查:异常白名单是否收窄到超时、连接错误和 502、503、504;重试次数和总等待时间是否符合接口的超时预算;退避是否是指数加抖动;写操作是否有幂等键;失败日志是否包含重试次数、等待时间和最后一次异常。把这些规则写进一个装饰器而不是每个接口各写一份,后续调整策略时只需要改一处,线上行为也更容易保持一致。
- 点赞
- 收藏
- 关注作者
评论(0)