Python HTTP 客户端的重试与超时:写出不把故障放大的请求代码

举报
yd_299366129 发表于 2026/11/03 20:21:18 2026/11/03
【摘要】 很多线上故障的起点并不是第一次请求失败,而是重试把一次短暂抖动放大成雪崩:上游刚开始限流,下游几百个实例同时原地重试,限流就变成了持续过载。本文把重试和超时拆开讲清楚:什么能重试、超时怎么分、退避怎么写、以及一个可以直接复用的 Python 示例。 一、先分清:什么能重试,什么不能重试能重试的,一般是“结果不确定、且重做无害”的请求:网络超时、连接被重置、DNS 偶发失败;服务端返回 429...

很多线上故障的起点并不是第一次请求失败,而是重试把一次短暂抖动放大成雪崩:上游刚开始限流,下游几百个实例同时原地重试,限流就变成了持续过载。本文把重试和超时拆开讲清楚:什么能重试、超时怎么分、退避怎么写、以及一个可以直接复用的 Python 示例。

一、先分清:什么能重试,什么不能重试

能重试的,一般是“结果不确定、且重做无害”的请求:

  • 网络超时、连接被重置、DNS 偶发失败;
  • 服务端返回 429、502、503、504;
  • 幂等方法:GET、HEAD、PUT、DELETE 等,重放一次不会产生第二份副作用。

不能盲目重试的,恰恰是很多事故的来源:

  • 400、401、403、404:请求本身或权限有问题,重试多少次结果都一样,只会浪费配额;
  • 非幂等的 POST 下单、扣款、创建资源:除非服务端支持幂等键,否则重试可能重复执行。

一个实用规则:先按“状态码 + 方法是否幂等”决定是否重试,再谈重试几次。

二、超时要分开设,不要只给一个数字

在 requests 里,timeout 可以传一个元组,分别表示连接超时和读取超时:

resp = requests.get(url, timeout=(3, 10))
  • 连接超时(3 秒):TCP 建连、TLS 握手迟迟完成不了,尽早放弃;
  • 读取超时(10 秒):连接已建立,但服务端迟迟不返回数据,也要设上限。

只设一个很大的总超时,或者干脆不设超时,是最常见的隐患:线程池被慢请求占满,表面上看是“接口变慢”,实际上是本服务先被拖垮。

三、退避要用“指数 + 抖动”,不要固定间隔

固定间隔重试有一个隐蔽问题:大量客户端在同一时刻失败、同一时刻重试,流量会周期性地撞在一起。正确做法是指数退避加随机抖动:

sleep = min(cap, base * (2 ** attempt)) + random.uniform(0, jitter)

例如 base=0.5 秒、cap=8 秒:第 1 次约 0.5 秒,第 2 次约 1 秒,第 3 次约 2 秒,再加 0 到 0.5 秒的随机抖动。这样既给上游恢复时间,又把重试流量打散。

同时要遵守 429 响应里的 Retry-After:服务端已经明确告诉你等多久,就不要用自己的退避覆盖它。

四、重试要有总预算,而不是只数次数

“最多重试 3 次”还不够,还要给整个调用一个总时间预算。例如单次读取超时 10 秒、重试 3 次,最坏情况就是 40 秒以上。如果调用方自己的超时只有 5 秒,重试逻辑在它看来永远等不到结果。

建议同时约束三件事:单次超时、重试次数、总耗时上限。任何一个超了就立刻失败并记录,而不是继续重试。更进一步,可以在调用方做熔断:连续失败达到阈值后短时间内直接拒绝,避免持续冲击已经不健康的下游。

五、一个可复用的示例

import random
import time

import requests

RETRYABLE_STATUS = {429, 502, 503, 504}


def fetch_json(url, *, timeout=(3, 10), max_retries=3, deadline=30.0):
    start = time.monotonic()
    last_error = None
    for attempt in range(max_retries + 1):
        try:
            resp = requests.get(url, timeout=timeout)
            if resp.status_code in RETRYABLE_STATUS:
                # 把可重试状态码也走统一的退避逻辑
                raise requests.HTTPError(f"retryable status: {resp.status_code}")
            resp.raise_for_status()
            return resp.json()
        except (requests.Timeout, requests.ConnectionError, requests.HTTPError) as exc:
            last_error = exc
            if attempt == max_retries:
                break
            elapsed = time.monotonic() - start
            if elapsed >= deadline:
                break
            sleep = min(8.0, 0.5 * (2 ** attempt)) + random.uniform(0, 0.5)
            sleep = min(sleep, deadline - elapsed)
            time.sleep(sleep)
    raise RuntimeError(f"fetch failed after retries: {url}") from last_error

这段代码刻意没有处理 POST:非幂等写操作要先确认服务端是否支持幂等键(例如 Idempotency-Key 请求头),确认后再把重试逻辑套上去,否则宁可让调用方显式决定是否重发。

六、上线前检查清单

  • 是否区分了连接超时和读取超时,两个值都有上限;
  • 是否只对幂等请求和可重试状态码重试,不重试 400、401、403、404;
  • 退避是否是指数加抖动,是否遵守 Retry-After;
  • 是否有重试次数和总时间预算,失败时是否记录了第几次尝试和最终错误;
  • 是否做过重试风暴演练:上游限流时,下游重试流量会不会反而把上游打满。

结语

重试不是“多试几次总会成功”的安慰剂,而是一份需要预算的策略:给对场景、给对间隔、给对上限,它才能把偶发抖动消化掉;否则,它只会把一次小故障复制

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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