给 Python 脚本加一个可复用的重试装饰器:指数退避与异常分类

举报
yd_240716394 发表于 2026/10/28 18:14:49 2026/10/28
【摘要】 给 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,然后在终端执行该文件。观察输出时重点看三点:成功场景是否在第三次调用时返回结果,参数错误场景是否没有等待就直接失败,持续超时场景是否在三次尝试后结束。如果三点都符合预期,说明异常分类和退避逻辑都已经生效。


五、实际使用时的常见坑


第一个坑是把重试次数设得过大。重试次数越多,任务总耗时越不可控,下游超时和定时任务堆积的风险也越大。建议先从三到四次开始,再根据接口的恢复时间和任务的时限调整。第二个坑是忘记设置最大延迟。没有上限的指数退避可能让一次调用等待几分钟,这在很多定时任务里是不可接受的。第三个坑是在循环里重试不可重试的异常,例如把所有异常都当作可重试异常,这会掩盖真正的程序错误。


第四个坑是日志太少。重试过程如果没有任何输出,排查问题时会很难判断失败是发生在第一次调用还是最后一次调用。至少记录第几次失败、异常类型和等待时间,生产环境里可以把打印换成日志记录。第五个坑是把装饰器用在异步函数上。本文的实现只适用于同步函数,异步函数需要另外写一个使用等待而不是睡眠的版本,不能直接混用。第六个坑是在重试里共享可变状态,例如在失败后继续使用已经被部分修改的数据,必要时应该在每次尝试开始前重新准备输入。


六、一个落地检查清单


在把这个装饰器用到真实脚本之前,可以按下面清单快速检查一遍:接口调用是否幂等或者是否允许重复提交;可重试异常是否只包含临时性故障;放弃异常是否覆盖参数错误和权限错误;最大尝试次数和最大延迟是否符合任务时限;是否开启抖动以避免同步重试;失败后是否保留原始异常和足够日志;定时任务的超时时间是否大于最坏情况下的总等待时间。如果这些问题都有明确答案,重试逻辑就不再是临时拼凑的代码,而是一个可以长期复用的基础设施。


总结一下,好的重试装饰器并不复杂,关键在于克制。它用指数退避控制节奏,用抖动打散并发,用异常分类守住边界,用日志保留可观测性。把这四点做扎实,脚本在面对网络抖动和短暂故障时就会稳定很多,而真正的程序错误也不会被无意义的重试掩盖。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。