给 Python 脚本加一个可复用的重试装饰器:指数退避与异常分类
很多 Python 脚本一开始写得很直接:调一次接口,成功就继续,失败就直接退出。本地跑起来没有问题,到了定时任务或者弱网环境里,偶尔一次网络抖动、服务端短暂限流,就会让整个任务失败。于是有人在每个调用点都复制一段循环和等待逻辑,时间一长,代码里到处都是相似的重试逻辑,既难维护,也容易把本不该重试的错误也重试一遍。本文介绍一个可复用的重试装饰器,核心思路只有两点:第一,用指数退避控制等待时间,避免对故障服务造成持续冲击;第二,用异常分类决定哪些错误值得重试,哪些错误应该立刻失败。
一、先想清楚重试的边界
重试不是万能药,它只适合临时性故障。网络超时、连接被重置、服务端返回短暂不可用,这类问题过一会儿可能自己恢复,值得重试。参数错误、权限不足、数据格式错误、文件不存在,这类问题重试一百次结果也不会改变,只会浪费时间,甚至可能重复产生副作用。因此,装饰器必须让调用方明确声明可重试的异常类型,同时支持一个放弃列表,放弃列表的优先级高于重试列表。
另一个边界是幂等性。查询类操作重试一般比较安全,而扣款、创建订单、发送通知这类写操作,重试之前必须确认接口是否支持幂等键或者是否允许重复提交。如果接口本身不幂等,盲目重试可能导致重复执行。装饰器解决不了业务幂等的问题,它只能保证重试行为本身可控、可观测。
二、指数退避为什么比固定等待更好
固定等待的问题很直观:如果每次失败都只等一秒,大量任务同时失败时,它们会在同一时刻再次冲击服务端,形成新的波峰。指数退避的做法是让等待时间随失败次数增长,例如第一次等一秒,第二次等两秒,第三次等四秒,之后再设置一个上限,避免等待时间无限拉长。
只做到指数增长还不够,还需要抖动。抖动就是在计算出的等待时间附近加一个随机波动,让不同任务的重试时刻错开。没有抖动的指数退避在并发场景下仍然容易出现同步重试,而加入随机抖动之后,系统恢复过程会平滑很多。本文的实现使用全抖动思路:在零到当前退避上限之间随机取一个等待时间,既保留了退避趋势,又打散了重试时刻。
三、一个可复用的装饰器
下面的代码只依赖标准库,可以直接放进脚本或者工具模块里复用。它支持同步函数,保留原函数的名称和文档,支持设置最大尝试次数、初始延迟、增长因子、最大延迟、是否启用抖动,以及重试异常和放弃异常。每次重试之前会打印一条日志,方便在定时任务的输出里追踪重试过程。
import functools
import random
import time
def retry(attempts=4, base_delay=1.0, factor=2.0, max_delay=30.0, jitter=True, retry_on=(TimeoutError, ConnectionError), give_up_on=()):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
delay = base_delay
last_error = None
for attempt in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except give_up_on as error:
raise
except retry_on as error:
last_error = error
if attempt >= attempts:
raise
wait = min(delay, max_delay)
if jitter:
wait = random.uniform(0, wait)
print(f"第 {attempt} 次失败:{error},{wait:.2f} 秒后重试")
time.sleep(wait)
delay = min(delay * factor, max_delay)
raise last_error
return wrapper
return decorator
这段代码有几个值得注意的细节。第一,放弃异常先于重试异常判断,这样即使某个异常同时出现在两个列表里,也会立刻失败。第二,等待时间在每次失败后乘以增长因子,并且始终不超过最大延迟。第三,抖动在取上限之后再随机化,而不是在固定值上加减一个小量,这样打散效果更明显。第四,装饰器不吞掉最终异常,尝试次数用完之后会把最后一次异常原样抛出,调用方仍然可以按正常流程处理失败。
四、三个可以直接运行的验证场景
为了验证装饰器是否按预期工作,可以准备一个会间歇失败的函数。第一个场景模拟网络抖动:前两次抛出连接错误,第三次返回成功,装饰器应该在两次等待之后拿到结果。第二个场景模拟参数错误:函数直接抛出数值错误,由于数值错误不在默认重试列表里,装饰器应该立刻失败,而不是等待重试。第三个场景模拟持续故障:函数每次都抛出超时错误,装饰器应该在用完尝试次数后抛出异常,而不是无限循环。
import time
state = {"count": 0}
@retry(attempts=4, base_delay=0.5, factor=2.0, max_delay=5.0)
def flaky_task():
state["count"] += 1
if state["count"] < 3:
raise ConnectionError("模拟网络抖动")
return "第三次调用成功"
@retry(attempts=3, base_delay=0.5)
def bad_argument_task():
raise ValueError("参数错误,不应重试")
@retry(attempts=3, base_delay=0.5)
def always_timeout_task():
raise TimeoutError("服务端持续超时")
if __name__ == "__main__":
print(flaky_task())
try:
bad_argument_task()
except ValueError as error:
print(f"立刻失败,符合预期:{error}")
try:
always_timeout_task()
except TimeoutError as error:
print(f"用完次数后失败,符合预期:{error}")
运行步骤也很简单:把装饰器和上面三个函数保存到同一个文件,例如保存为 retry_demo.py,然后在终端执行该文件。观察输出时重点看三点:成功场景是否在第三次调用时返回结果,参数错误场景是否没有等待就直接失败,持续超时场景是否在三次尝试后结束。如果三点都符合预期,说明异常分类和退避逻辑都已经生效。
五、实际使用时的常见坑
第一个坑是把重试次数设得过大。重试次数越多,任务总耗时越不可控,下游超时和定时任务堆积的风险也越大。建议先从三到四次开始,再根据接口的恢复时间和任务的时限调整。第二个坑是忘记设置最大延迟。没有上限的指数退避可能让一次调用等待几分钟,这在很多定时任务里是不可接受的。第三个坑是在循环里重试不可重试的异常,例如把所有异常都当作可重试异常,这会掩盖真正的程序错误。
第四个坑是日志太少。重试过程如果没有任何输出,排查问题时会很难判断失败是发生在第一次调用还是最后一次调用。至少记录第几次失败、异常类型和等待时间,生产环境里可以把打印换成日志记录。第五个坑是把装饰器用在异步函数上。本文的实现只适用于同步函数,异步函数需要另外写一个使用等待而不是睡眠的版本,不能直接混用。第六个坑是在重试里共享可变状态,例如在失败后继续使用已经被部分修改的数据,必要时应该在每次尝试开始前重新准备输入。
六、一个落地检查清单
在把这个装饰器用到真实脚本之前,可以按下面清单快速检查一遍:接口调用是否幂等或者是否允许重复提交;可重试异常是否只包含临时性故障;放弃异常是否覆盖参数错误和权限错误;最大尝试次数和最大延迟是否符合任务时限;是否开启抖动以避免同步重试;失败后是否保留原始异常和足够日志;定时任务的超时时间是否大于最坏情况下的总等待时间。如果这些问题都有明确答案,重试逻辑就不再是临时拼凑的代码,而是一个可以长期复用的基础设施。
总结一下,好的重试装饰器并不复杂,关键在于克制。它用指数退避控制节奏,用抖动打散并发,用异常分类守住边界,用日志保留可观测性。把这四点做扎实,脚本在面对网络抖动和短暂故障时就会稳定很多,而真正的程序错误也不会被无意义的重试掩盖。
- 点赞
- 收藏
- 关注作者
评论(0)