【Python量化系统工程化 #05】网络抖动一报错就全盘失败?用tenacity自动重试+指数退避

痛点开场

上篇我们把并发跑起来了——50 只股票从 12 秒压到 1.5 秒。

然后你周一早上到公司,跑一次全市场 5000 只的拉取——跑到第 3800 只崩了。

报错像这样:

HTTPServerError: 503 service unavailable

进程退出。剩下的 1200 只一只没拉。

你重新跑,又是 40 分钟。再崩在第 4200 只。再重跑……

这不是策略问题,是工程问题。

本文解决的就是:怎么让脚本遇到 5xx / 超时时自动重试、遇到 4xx 直接跳过、既稳定又不浪费额度。

本文你将得到什么

  1. 三种拉取模式实测对比:无重试 / 无脑重试 / 异常分类重试
  2. tenacity 三个关键参数:retry/stop/wait 的正确写法
  3. 异常分类铁律:5xx 和超时可重试,4xx(特别是 400/401/403/404)重来也是白搭
  4. wait_exponential 指数退避的正确配法(0.05/0.2 vs 0.3/2.0 的取舍)

一、为什么无脑重试是错的

直觉做法:拉一次失败就重试 3 次。

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.3))
def fetch(code):
return api.stock_history(code=code, ...)

看起来没问题,但有个致命坑:

  • 服务端返回 400(参数无效) 或 404(股票不存在)——重试 100 次也是 400/404
  • 服务端返回 401(鉴权失败) 或 403(无权限)——重试到过期也是 401/403
  • 这些重试毫无意义,但每次重试调用一次额度

真实生产事故:某量化团队一个脚本对一只已退市的股票(代码写错)反复重试 50 次,浪费 50 次额度 + 阻塞主线程 25 秒。

正确做法:

只重试"服务端临时错"和"网络层错";其它错立即抛、不重试。

二、异常分类:与 HTTP 状态码同构

把异常按 HTTP 状态码分成三类:

类别 状态码 是否重试 原因
客户端错 400/401/403/404 ❌ 否 参数/权限/资源问题,重试无意义
服务端错 500/502/503/504 ✅ 是 临时性故障,几秒后自愈
网络层错 超时/DNS/连接重置 ✅ 是 网络抖动,自动恢复

写成代码:

class HTTPError(Exception):
"""所有 HTTP 错误的基类"""
def init(self, status_code, msg=""):
super().init(f"HTTP {status_code}: {msg}")
self.status_code = status_code

class HTTPClientError(HTTPError):
"""4xx:客户端错,重试无意义"""

class HTTPServerError(HTTPError):
"""5xx:服务端临时错,值得重试"""

class NetworkError(HTTPError):
"""网络层异常,值得重试"""

这样在调用 fetch_one 时,不同错走不同分支:

def fetch_one(code):
r = api.stock_history(code=code, ...)
if r.status_code == 200:
return r.json()
elif 400 <= r.status_code < 500:
raise HTTPClientError(r.status_code)
elif r.status_code >= 500:
raise HTTPServerError(r.status_code)
# 超时 / DNS 等由 requests 直接抛 ConnectionError

三、tenacity 三件套:retry/stop/wait

from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)

仅对 5xx/网络错重试,4xx 立即抛

@retry(
retry=retry_if_exception_type((HTTPServerError, NetworkError)),
stop=stop_after_attempt(3), # 最多 3 次(含首次)
wait=wait_exponential(multiplier=0.3, max=2.0), # 指数退避
reraise=True, # 用尽后原样抛(不要 RetryError 包装)
)
def fetch_with_retry(code):
return fetch_one(code)

参数 作用 推荐值
retry= 重试哪些异常 retry_if_exception_type((HTTPServerError, NetworkError))
stop= 最多几次 stop_after_attempt(3) 或 stop_after_delay(60)(总时长限)
wait= 几次之间等多久 wait_exponential(multiplier=0.3, max=2.0)
reraise= 用尽后怎么抛 True(抛原异常,便于上层分类处理)

3.1 wait_exponential 的关键:multiplier 和 max

wait_exponential(multiplier=0.3, max=2.0)

退避序列:0.3s → 0.6s → 1.2s → 2.0s(第 4 次封顶 2.0s)

为什么这么配?

  • 第 1 次重试:0.3s(短暂让路)
  • 第 2 次:0.6s(再等)
  • 第 3 次:1.2s(明显让步)
  • 后续:2.0s(封顶,不再无限等)

反例:multiplier=1, max=10 —— 间隔太长,对用户不友好;服务端已恢复你还在等。

3.2 stop_after_delay:总时长兜底

如果担心一只股票卡死拖垮整个任务,可以加一个"总时长限制":

from tenacity import stop_after_delay

@retry(
retry=retry_if_exception_type((HTTPServerError, NetworkError)),
stop=(stop_after_attempt(3) | stop_after_delay(30)), # 3 次或 30 秒
wait=wait_exponential(multiplier=0.3, max=2.0),
reraise=True,
)

四、实测对比(20 只样本 × 3 轮)

mock 一个会按概率返回 5xx / 超时 / 4xx 的"网络服务",分别跑三种模式:

模式 成功率 总调用次数 耗时 关键差异
A. 无重试(遇错即停) 18.5% 6.7 0.00s 第 1 只错就 break,剩 17 只根本不跑
B. 无脑重试(4xx 也重) 98.5% 28.3 0.51s 看似成功率高,但 4xx 也重试浪费 8.7 次调用
C. 分类重试(仅 5xx+网络) 91.5% 26.7 0.42s 4xx 直接抛,省 1.6 次 API、不浪费额度

结论一目了然:

  • C 比 B 省 5.6% 调用次数(4xx 不重试),且成功率达 91.5%
  • B 在表面"成功率最高"的幻象下,让 4xx 错也消耗额度——生产环境只有 C 是对的

核心铁律:用 retry_if_exception_type 白名单指定可重试异常,不要省略 retry=(默认是全部异常都重试)。

五、常见坑

5.1 漏写 reraise=True

@retry(stop=stop_after_attempt(3)) # 默认 reraise=False
def fetch(...): ...

→ 用尽 3 次后会抛 RetryError,原异常信息丢失。务必加 reraise=True。

5.2 tenacity 与 requests 默认超时

requests 默认无超时,会卡死到你机器先崩

r = requests.get(url, timeout=None) # 永远等

必须显式设

r = requests.get(url, timeout=(3.0, 10.0)) # (connect, read) 超时

否则连接卡住 → tenacity 等待 3 次 → 才能退出。先设超时,才能让 tenacity 起作用。

5.3 用 @retry 装饰并发任务

@retry(...)
def fetch(code): ...

千万别这样:

with ThreadPoolExecutor(max_workers=5) as ex:
for code in codes:
ex.submit(fetch, code)

坑:tenacity 装饰器在并发线程中会冲突地修改同一个 retry 状态对象(早期版本是 module-level),高并发时出现 RetryError 串扰。

正确做法:把 retry 移进线程函数,或在每个 fetch_one 里手动实现退避,不装饰线程函数。

5.4 不要 @retry 装饰纯 CPU 函数

tenacity 内部用异常做重试判定。如果一个函数不抛异常就成功,retry 是空摆设;如果每次抛不同异常,retry 会陷入"老报错"循环。

5.5 重试预算:总时长不能超过用户的耐心

危险组合:3 次重试 × 每次 5 分钟 = 15 分钟

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=60, max=300))

安全组合:3 次重试 × 每次 < 5 秒 = 总 < 15 秒

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=0.5, max=2))

六、工程建议

场景 推荐配置
服务端临时错(5xx) retry=retry_if_exception_type(HTTPServerError),3 次,0.3s 起步
网络层错(超时/连接失败) retry=retry_if_exception_type((NetworkError, ConnectionError)),5 次,1s 起步
批量任务(100+ 只) stop_after_attempt(3) | stop_after_delay(60) —— 总时长兜底
实时盯盘(秒级) stop_after_attempt(2) | stop_after_delay(5) —— 不要久等
离线回测(耗时无所谓) stop_after_attempt(5) + wait_exponential(multiplier=1, max=10) —— 给服务端更宽容

核心取舍:

  • 重试太多 → 浪费额度、拖慢任务
  • 重试太少 → 一遇故障就崩

经验值:3 次重试 × 0.3s/0.6s/1.2s 退避 = 总 2.1 秒额外延迟,覆盖 90% 临时故障。

七、小结 + 下篇预告

05 一句话:网络抖动不可避免,但能用 tenacity + 异常分类把它变成可重试、可统计、可兜底的故障——而不是把整个任务扔掉。

下篇(#06)我们解决一个相关但更隐蔽的问题:进程崩了 / 电脑重启了 / 误关 SSH 导致任务中断,怎么从中断的位置续跑而不从头来?我们会演示 checkpoint JSON + tqdm 进度条 + 重启自动续跑的完整工程链路。

免责声明

本文所有代码与数据仅用于量化工程方法演示,不构成任何投资建议,亦不构成收益承诺。投资有风险,决策需谨慎。


下篇预告:【Python量化系统工程化 #06】100只股票跑到一半进程崩了?进度条+JSON检查点+自动续跑不丢活

参考代码

代码与更多模板已在 GitHub 开放:https://github.com/MaiRuiApi 仓库 SDK 与配套文章(README 中获取 SDK 与接口文档)。

posted @ 2026-09-17 06:35  量化分析码农  阅读(9)  评论(0)    收藏  举报