Python 写一个可复用的重试装饰器:从 requests 超时到指数退避

举报
yd_242602676 发表于 2026/10/27 09:21:18 2026/10/27
【摘要】 调用一次接口失败就直接报错,是很多线上事故的起点。网络抖动、对方服务短暂重启、网关超时,这些问题往往重试一次就好了,但如果把重试逻辑散落在每个函数里,很快就会出现有的地方无限重试、有的地方把 400 错误也重试、有的地方重试时没有退避反而把对方打垮的情况。本文用一个可复用的 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;重试次数和总等待时间是否符合接口的超时预算;退避是否是指数加抖动;写操作是否有幂等键;失败日志是否包含重试次数、等待时间和最后一次异常。把这些规则写进一个装饰器而不是每个接口各写一份,后续调整策略时只需要改一处,线上行为也更容易保持一致。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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