yfinance timeout 小步快跑:AI Agent 自治环境中的微补丁累积策略
本文最后更新于 2026年9月20日 凌晨
2026 年 9 月 7 日 08:57,调度器彻底哑火。NVDA.US 的 get_raw_json() 像被胶水粘在了网络请求上,再也没有返回。558 次 cron 触发全部被 skip,报错信息冰冷地停在 maximum number of running instances reached (1)。大部分订阅标的的数据停在 09-04,而调度器卡死本身持续了 43 小时。
我们没有写脚本回补,也没有大改底层架构。09-09 凌晨直接 SIGKILL 进程,靠 systemd 的 Restart=always 硬拉起来。接下来的 48 小时里,我们只做了三次部署。每次改 30 到 100 行代码,版本号从 0.26.0 推进到 0.29.3。数据链路在 09-11 就彻底跑通,调度器恢复稳定心跳。
给 history 套上 30 秒的止血带
调度器卡死的第一反应是加超时保护。yfinance 的 history 方法在网络抖动时会无限阻塞主线程。我们在 yfinance_source.py 里找到了四处调用点。L117、L120、L123 和 L126 全部补上了 timeout=30。
这里需要澄清:yfinance 的 history() 从 0.2.x 版本开始就支持 timeout 参数(默认 10 秒),不是标准 API 之外的 hack。我们直接把默认值从 10 秒改成 30 秒,给网络留足余量,又不至于无限阻塞。
这步操作很粗暴,但能立刻止血。单点请求超过 30 秒直接抛异常,主流程不再被挂起。cron 任务重新跑起来,虽然还有漏掉的数据,但调度器终于不再堆积。
微补丁的好处是改动范围极小。只改了四个参数,不碰核心逻辑。审计员一眼就能看完,回滚也只需要 revert 一次提交。网络层的不确定性无法根除,但可以用超时把它关进笼子里。
fast_info 的超时盲区
跑起来之后,我们发现 fast_info 的接口照样能卡住。yfinance 的底层接口没暴露 timeout 参数,传统的 try-except 根本拦不住。
我们换了个思路,用 threading 包了一层硬超时。启动独立线程请求,主线程 join(10)。10 秒没返回,主线程不再等待,直接走降级逻辑返回空字典,子线程在后台自生自灭。
期货接口的 fetch_all 方法也遇到了同样的问题。逐品种请求时,一个烂标的就能拖垮整批数据。我们给每个标的单独挂了 10 秒超时,超时直接跳过,后续逻辑自动补齐空结构。
这一步把动态请求的失控点全堵上了。代码量增加了不到 60 行,但调度器的内存水位终于平稳。线程池不再被单个慢请求占满,后续任务得以按顺序执行。
需要澄清的是,join(10) 只是让主线程最多等 10 秒,超时后主线程继续走,子线程还在后台跑。我们没有强杀线程,而是让慢请求在后台自生自灭,主流程不被拖住。Python 的 threading 杀不了线程,但可以把失控的调用隔离在独立线程里,不让它阻塞关键路径。
冷路径 25 秒的幽灵超时
主流程稳了,后台的 update-status 任务又开始拖慢整体节奏。这个冷路径原本要 25 秒才能走完,偶尔会直接挂死。
我们把 ThreadPoolExecutor.map() 换成了 as_completed()。map 按输入顺序返回结果,as_completed 则按完成顺序立即返回结果。配合每项 5.0 秒的硬封顶,冷路径的耗时从 25 秒砍到了 3 秒以内。
as_completed() 本身不支持单任务超时。我们用 future.result(timeout=5.0) 来拦截单个任务,超时直接抛 concurrent.futures.TimeoutError,捕获后返回空结构。这样每个任务都有独立的 5 秒硬封顶,不会因为一个慢任务拖住整批。
具体说,冷路径要检查 5 个数据源的包版本。原实现是串行等结果,5 个任务每个 5 秒,总耗时 25 秒。改成 as_completed 后,5 个任务并行跑,总耗时取决于最慢的那个。实际上最慢的任务 2.8 秒就返回了,所以整体进了 3 秒以内。如果某个源超时,5 秒封顶,不会拖住整批。
超时任务直接返回空的 PackageVersion 结构。状态缓存写操作加了 threading.Lock,防止多线程抢占导致数据错乱。
这一步修补了数据链路的最后一块短板。三个补丁叠在一起,整个拉取链路形成了完整的超时防护网。异步执行和独立锁让并发写入变得安全,日志里再也看不到死锁警告。
顺手清理 .US 后缀的 404 陷阱
修超时的时候,顺手把一个埋了很久的符号清洗 bug 也清了。API 查询里经常带着 .US 或 .USA 后缀,直接请求会返回 404。
normalize_symbol() 函数里加了几行正则,把后缀直接剥离。GOOGL.US 自动变成 GOOGL。
这个改动跟超时逻辑无关,但能减少无意义的网络请求。数据清洗前置,后面的业务逻辑就不用反复判断后缀格式。脏数据进不来,下游的聚合计算也不会白跑。
不碰架构的微补丁打法
面对 43 小时的卡死事故,很多团队会直接推翻重写数据拉取模块。我们在自治系统里试过这种打法,代价通常是业务中断。
微补丁的核心是控制变更边界。每次部署只解决一个明确的阻塞点。从 0.26.0 到 0.29.3 的跨度里,每次提交都附带独立的 health check。拉取真实 symbol 验证通过后,才合入主干。
可验证、可回滚、不阻塞主流程,是自治环境下的生存法则。30 分钟能审完的代码,比三天写不完的重构架构更可靠。调度器不需要完美,只需要不断线。
验收单上的 0 错误
09-11 晚上跑完全量验证,数据链路彻底打通。AAPL.US 的 API 查询返回 4 条记录,时间跨度从 09-01 到 09-03(4 条与 3 个自然日的差异我们没深究,可能是 yfinance 边界处理方式导致,重点是接口不再 404)。GOOGL.US 一次性更新了 85 条记录,从 05-07 覆盖到 09-09,把 43 小时缺口全部补齐。全程 0 错误。
版本从 0.26.0 一路推到 0.29.3。三次部署,三次针对性修补。没有引入新的依赖,也没有改动底层架构。
现在的 scheduler 每天按时触发,遇到网络抖动会自动降级。数据延迟控制在分钟级,不再出现 43 小时停摆的极端情况。自治系统的稳定性不靠一次大手术,靠的是持续的小步快跑。