Python HTTP 客户端的重试与超时:写出不把故障放大的请求代码
很多线上故障的起点并不是第一次请求失败,而是重试把一次短暂抖动放大成雪崩:上游刚开始限流,下游几百个实例同时原地重试,限流就变成了持续过载。本文把重试和超时拆开讲清楚:什么能重试、超时怎么分、退避怎么写、以及一个可以直接复用的 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; - 是否有重试次数和总时间预算,失败时是否记录了第几次尝试和最终错误;
- 是否做过重试风暴演练:上游限流时,下游重试流量会不会反而把上游打满。
结语
重试不是“多试几次总会成功”的安慰剂,而是一份需要预算的策略:给对场景、给对间隔、给对上限,它才能把偶发抖动消化掉;否则,它只会把一次小故障复制
- 点赞
- 收藏
- 关注作者
评论(0)