作者在免费模型服务器上部署了一个重试循环 worker,本地 mock 测试完美,线上却引发 API 提供商限流。根因是同 IP 下所有租户共享出口,导致重试请求叠加形成风暴。
这个 worker 已经运行了六个小时,第一条 429 才出现在日志里。直觉告诉我应该怪 API 提供商,因为代码在本地测试中全部通过,diff 看起来也完全合理。然后我注意到了更糟糕的情况:重试循环确实在重试,但每次尝试之间的时间戳几乎正好相差一秒。这个间隔就是我差点滑过的线索。
上周我在 MonkeyCode 里让一个免费模型写一个小型 worker,从外部 API 拉取事件并存入本地队列。披露:本文是 MonkeyCode 产品推广的一部分。该模型返回了一个紧凑的同步循环,我把它部署到 MonkeyCode 的免费服务器选项上,没有仔细审查失败路径。本地运行 mock endpoint 看起来很完美,所以 worker 直接进了定时任务。
第一个迹象是 API 提供商返回了一堆 429 响应,第二个迹象是整个服务器开始对无关请求超时。我的 worker 与该免费服务器上的所有其他租户共享同一个出口 IP,而且我的重试循环正在同时对他们所有人狂敲 API。提供商不仅限制了我的 worker,还限制了整个 IP 段,这意味着无辜的邻居也被拦截了。怎么调试一个从来不失败也从来不成功的循环?先看时间戳。
日志一遍遍写着"1 秒后重试",我扫过去还以为这是健康的退避。当我真正计算连续日志行之间的时间差时,全都是 1.0 秒,根本不是指数退避。一个小小的 Python 脚本让模式一目了然:
import re
from datetime import datetime
pattern = re.compile(r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] retrying")
last = None
for line in open("worker.log"):
match = pattern.search(line)
if not match:
continue
now = datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
if last:
print(f"gap: {(now - last).total_seconds():.1f}s")
last = now
输出是一长列 gap: 1.0s,重复了成千上万次。消息说"正在重试",但数据说"在空转"。
我把重试循环提取成独立脚本,指向一个永远返回 429 的小型 mock 服务器。bug 在几秒内就变得显而易见:循环没有尝试次数上限,只有一个固定的 1 秒 sleep,而且从不读取 Retry-After 头。下面是这个模型生成代码的形态:
import time
import requests
def pull_events(url):
while True: # 永远重试
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
except requests.HTTPError as error:
if error.response.status_code == 429:
time.sleep(1) # 固定延迟,忽略 Retry-After
continue
raise
代码简单、可读,但在负载下才会暴露问题。本地上一个 worker 每秒重试一次是无感的。在共享出口 IP 上,几十个租户做同样的事情就会制造同步的雷鸣般的羊群效应,API 提供商的反应是屏蔽整个 IP 段。
我比较了笔记本和免费服务器的出口 IP,差异解释了一切:
curl -s https://api.ipify.org # 笔记本: 203.0.113.42
# 免费服务器: 198.51.100.17
单个重试循环在独立 IP 上只是小麻烦。在共享 IP 上,它就成了公共麻烦,因为每个租户的流量在 API 提供商看来都是一模一样的。修复必须得体——不只是为我的 worker,还要为所有共享同一地址的人。
import random
import time
import requests
MAX_ATTEMPTS = 5
def pull_events(url):
for attempt in range(MAX_ATTEMPTS):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.json()
except requests.HTTPError as error:
if error.response.status_code != 429:
raise
delay = _retry_delay(attempt, error.response)
print(f"attempt {attempt + 1} hit 429; waiting {delay:.1f}s")
time.sleep(delay)
raise RuntimeError("API 在 5 次尝试后仍返回 429")
def _retry_delay(attempt, response):
retry_after = response.headers.get("Retry-After")
if retry_after:
return float(retry_after)
base = min(2 ** attempt, 30)
return base + random.uniform(0, base * 0.1)
三个改动至关重要:硬性限制尝试次数、尊重 Retry-After、以及添加 jitter(抖动)让重试不会跨租户同步。jitter 这部分看起来是可选的,直到你看到 30 个 worker 在同一秒一起重试。
我写了一个小测试,让下一个模型生成的重试循环不会静默退化:
from unittest.mock import Mock, patch
def test_backoff_respects_retry_after():
response = Mock()
response.headers = {"Retry-After": "7"}
assert _retry_delay(0, response) == 7.0
def test_backoff_grows_with_jitter():
response = Mock()
response.headers = {}
with patch("random.uniform", return_value=0):
delays = [_retry_delay(i, response) for i in range(5)]
assert delays == [1.0, 2.0, 4.0, 8.0, 16.0]
现在重试策略是测试套件的一部分,而不是 review 时的一种感觉。如果未来的模型重写了 worker 并丢弃了 jitter,测试会在 API 提供商之前失败。
如果你的 API 是内部的且重试很少,这种方法就过度设计了,因为固定的 1 秒 sleep 永远不会伤到任何人。这也不能替代阅读提供商的实际速率限制文档,因为 Retry-After 是一个提示而不是契约,有些 API 使用滑动窗口,这是多少 jitter 都无法解决的。而且如果你的工作负载确实是突发型的,正确的答案是带速率限制器的队列,而不是更巧妙的 sleep。
重试循环在 diff 里看起来是正确的,而这正是它通过 review 的原因。免费服务器没有破坏我的代码;它暴露了"为一个用户工作的代码"和"为一百个共享一个地址的用户工作的代码"之间的差别。下次你用免费模型生成 worker 时,让它展示失败路径,然后在部署前运行复现。模型会免费写出快乐路径;调试还是你的活。