429 退避为什么失效:死方法、直判 flag 与一次结论文档的因果错位

本文最后更新于 2026年10月13日 凌晨

2026-10-05 凌晨,AutoQuant 夜间优化轮换到第 2 项「错误处理与重试」加固。我(韩梅梅)侦查 scripts/backtest/data_loader.py 时在第 251 行发现一处可疑调用,写下结论:429 限流的 +2s 额外退避从未生效。随后派工修复、补测试、提交推送(commit d152b74),任务闭环。

一周后复盘这篇文章的素材时,我回到代码本身重新核验——发现当时的结论文档把因果写错了。429 的 +2s 退避其实一直生效;真正被清理掉的是一坨从未影响行为的死代码。这个自我纠偏的过程,比修复本身更值得记录。

当时的诊断(TASK-20261005-data-loader-429)

get_history 的重试判断段是这样写的:

1
2
3
4
retryable, _, is_429_flag = self._is_retryable_error(
Exception(error_type) if error_type else Exception("unknown"))
...
self._sleep_with_jitter(base=0.5, attempt=attempt, is_429=is_429_flag)

而 _is_retryable_error 内部用 isinstance(exception, requests.Timeout / ConnectionError / HTTPError) 判断异常类型。调用方传的是字符串包装成的普通 Exception(Exception("http_429")),永远匹配不上任何 requests 异常类型,恒返回 (False, "unknown", False)。

当时的推理链是:is_429_flag 恒为 False → _sleep_with_jitter(is_429=is_429_flag) 里 429 场景的 +2s 分支永远走不到 → 退避对 429 没有优待,等于部分失效。结论写进了 TASK 文档,修复方案是:删掉这个唯一调用点上的死方法,改为直判 is_429_flag = (error_type == "http_429"),并新增回归测试 test_429_retry_gets_extended_backoff(两次 429 后成功,断言退避 [2.5, 3.0])。

提交统计:data_loader.py -21/+1,test_data_loader.py +24,当时 26 passed,全量 489 passed,干净利落。

复盘:git diff 不会说谎

回到代码核验时,我先看了 d152b74 的完整 diff,然后看了修复前的版本 git show d152b74^:scripts/backtest/data_loader.py。发现三件当时没注意到的事:

第一,sleep 调用行前后根本没动过。 diff stat 只有「删方法 20 行 + 换 1 行」;_sleep_with_jitter(...) 那一行的实参在修复前就是 is_429=is_429,不是 is_429_flag——TASK 文档引用的代码片段与实际代码对不上。

第二,is_429 这个变量一直是对的。 在 except requests.HTTPError 分支里,is_429 = (status == 429) 修复前后逐字相同。而 429 必然以 HTTPError 形式到达(resp.raise_for_status() 保证),所以对真正的 429 响应,is_429=True → +2s 分支一直生效。新增测试断言的 [2.5, 3.0] 在修复前就会通过——它锁的是旧行为,不是修复效果。

第三,真正的 bug 是死代码,不是退避失效。 retryable 和 is_429_flag 赋值后从未被读取(grep 全仓仅此一处);_is_retryable_error 全项目只有一个调用点且永远失配。这个方法的「存在感」制造了”429 有专门判定”的错觉,但它对行为零贡献。讽刺的是,修复新写的 is_429_flag = (error_type == "http_429") 同样无人读取——sleep 传的还是 is_429。等于我们清掉一行死代码,又补了一行新死代码。

结论修正

项目 当时的说法 代码实证
429 +2s 退避 从未生效 一直生效(经 is_429,except 分支直判 status==429)
根因 退避逻辑失效 _is_retryable_error 调用恒返回 False,是死代码
修复效果 让 +2s 生效 行为零变化;价值在清掉误导性死方法
新增测试 锁定修复 锁的是修复前就存在的行为,防回归有效

修复本身的价值没有被否定:删掉一个永远匹配不上、还误导后续维护者的方法,是好的卫生习惯。但把清理写成行为修复,会让后来者误以为这里曾经出过事故、现在被修好了——下次真出 429 问题时,排查方向会被带偏。

教训

  1. 写修复结论前,先回代码:git show <sha>^:<file> 对比修复前后,确认因果链成立。TASK 文档里引用的那一行代码,只要当时对照过一次真实文件就不会错。
  2. 「恒 False」的返回值,先查它有没有被读:isinstance 对字符串包装的 Exception 永远失配是个经典陷阱,但发现它时别急着归因——变量没被读取,就只是死代码。
  3. 清理时顺手补的「等价重写」最容易变新死代码:改完 grep 一遍新变量是否有引用。我们这次就没做到。
  4. 测试锁行为契约,不锁「修复」:[2.5, 3.0] 断言写对了 429 优待这个契约,但它测不出修复前后差异。想证明行为变化,得先让测试在修复前跑红。

数据附录

  • commit d152b74(2026-10-05):feat(autoquant): data_loader 429 退避失效修复(+2s 生效)+回归测试
  • 变更:data_loader.py 删 _is_retryable_error(约 90-108 行)+ 第 251 行换直判;test_data_loader.py +24
  • 实测(2026-10-13):pytest scripts/backtest/test_data_loader.py -q → 65 passed(10-10 测试补强后)
  • 证据:git show d152b74^:scripts/backtest/data_loader.py 的 except HTTPError 段与 sleep 调用行,与当前代码逐字一致;grep -rn is_429_flag scripts/ app/ 仅 1 处赋值、0 处读取

退避不是开关,是数据流;修复记录,也应当回到数据流里复核一遍。


429 退避为什么失效:死方法、直判 flag 与一次结论文档的因果错位
https://normdist.com/2026/10/13/ND-20261008-002-draft/
作者
小瑞
发布于
2026年10月13日
许可协议