API 速率限制 — 头部信息、指数退避以及应对 429 错误
您遇到了429错误。API提示您需要放慢速度。以下是如何解析X-RateLimit-*头部信息、理解Retry-After字段,以及实现带抖动的指数退避策略,以确保您的集成在遇到速率限制时能够优雅处理,而不是持续冲击服务器。
你遇到了429错误。可能你的Webhook处理器崩溃了,也可能某个批量任务被静默丢弃了。API返回了“请求过多”的提示,以及一堆你可能已经滚动过去的响应头。
这些响应头才是关键。以下是它们的解读方法,以及如何编写不会使问题恶化的重试逻辑。
真正重要的响应头
大多数受速率限制的API在每次响应中都会返回这些头信息——不仅限于429错误:
- X-RateLimit-Limit ——你在当前时间窗口内允许的最大请求数。GitHub的REST API为认证用户每小时提供5000次请求;未认证请求则为60次。
- X-RateLimit-Remaining ——当前时间窗口内剩余的请求数。当这个值变为0时,下一次请求将返回429错误。
- X-RateLimit-Reset ——时间窗口重置的时间,以Unix时间戳表示。这是大多数开发者忽略的头,却是最有用的一个。
- X-RateLimit-Used (GitHub专用)——到目前为止已使用的请求数。与
Limit - Remaining类似,但可用于验证。 - Retry-After ——仅在429响应中出现。可能是以秒为单位的等待时间,也可能是HTTP日期字符串。如果API发送了该头,请使用它——它比你自己计算的更精确。
真实的GitHub响应头看起来如下:
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4823
X-RateLimit-Reset: 1716998400
X-RateLimit-Used: 177
X-RateLimit-Resource: core
这 X-RateLimit-Resource header是GitHub特有的:它们为REST API、搜索和GraphQL分别维护独立的配额池。消耗搜索配额(每分钟最多30次)不会影响核心配额——反之亦然。
Stripe不同
Stripe不使用 X-RateLimit-* 命名。它们的头信息前缀不同:
Stripe-Ratelimit-Limit: 100
Stripe-Ratelimit-Remaining: 97
Stripe-Ratelimit-Reset: 1716998460
在429响应中:
Retry-After: 30
Stripe的默认配额是每100个实时模式请求 每秒,而不是每小时。这一点比听起来更重要:如果你不进行端侧限流,一个导入500个客户的循环可能在不到5秒内耗尽该窗口。
Stripe还区分了请求速率限制和资源特定限制(例如短时间内创建过多客户)。429响应体中会明确指出你触碰了哪个限制——始终记录完整的响应体,而不仅仅是状态码。
解析重置时间戳
这 X-RateLimit-Reset 值是一个Unix时间戳。 1716998400 乍一看它没有意义,但很容易解码:使用 Unix 时间戳转换器 将其转换为可读的UTC时间,以查看重置还剩多久。
在代码中: reset_time - time.now() 给出从现在到窗口重置的秒数。但先检查 X-RateLimit-Remaining ——如果你仍有配额,就无需等待。
429响应体告诉你什么
仅凭429状态码是不够的。响应体通常会说明是哪个限制被触发了:
他们的 OG 图片包含帖子标题、日期和阅读时间
{
"message": "API rate limit exceeded for user ID 12345.",
"documentation_url": "https://docs.github.com/rest/overview/rate-limits"
}
Stripe:
{
"error": {
"code": "rate_limit",
"message": "Too many requests hit the API too quickly.",
"type": "invalid_request_error"
}
}
OpenAI更进一步:错误信息会说明你触碰的是每分钟令牌限制还是每分钟请求限制,这会彻底改变你的重试策略。始终记录完整的429响应体。
指数退避加抖动
最简单的修复方法:捕获429错误,等待1秒后重试。这种方法有两个问题:
- 如果你有多个工作进程同时访问同一个端点,它们都会等待1秒后同时重试——这会引发同步重试风暴,重现问题。
- 如果已经耗尽了每小时或每日配额,1秒的等待毫无意义。你只会收集到更多的429错误。
正确的做法是指数退避加抖动:每次重试的等待时间都比上一次更长,并加入随机成分以分散并发工作进程的重试时间。
import time
import random
import requests
def fetch_with_backoff(url, headers, max_retries=5):
base_delay = 1 # seconds
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code != 429:
return response
# Prefer Retry-After if the API provides it
retry_after = response.headers.get("Retry-After")
if retry_after:
wait = int(retry_after)
else:
# Fall back to X-RateLimit-Reset
reset = response.headers.get("X-RateLimit-Reset")
if reset:
wait = max(0, int(reset) - int(time.time()))
else:
# Pure exponential backoff with full jitter
cap = 60 # max wait: 60s
wait = random.uniform(0, min(cap, base_delay * (2 ** attempt)))
print(f"Rate limited. Attempt {attempt + 1}/{max_retries}. Waiting {wait:.1f}s")
time.sleep(wait)
raise Exception(f"Max retries exceeded after {max_retries} attempts")
该实现中的优先级顺序是刻意设计的:
- Retry-After 优先 ——如果API明确告诉你等待多久,请使用它。不要用自己计算的值去猜测。
- X-RateLimit-Reset 作为备选 ——计算实际到重置的秒数,而不是猜测一个固定延迟。
- 完全抖动作为最后手段 —
random.uniform(0, cap)将重试分布在退避窗口的整个范围内。AWS架构博客中将这种做法称为“完全抖动”,并指出它相比等量抖动或无抖动能显著减少服务器端的碰撞。 max(0, ...)在重置时 ——重置时间戳在你计算时可能已经过期。需防止出现负的等待时间导致你的处理器崩溃。
常见错误
将非429错误误认为是速率限制错误。 503是服务器错误,401表示你的凭据错误。在应用速率限制重试逻辑前,必须明确检查 status_code == 429 。
吞下429错误并返回空数据。 静默失败比抛出异常更难调试。请暴露错误。
使用固定延迟。 如果你在47分钟内耗尽了每小时配额,等待5秒毫无意义。应根据重置时间戳来计算。
无限重试。 设置一个 max_retries 上限,并在耗尽后抛出异常。某些429错误表示配额耗尽,直到下一个计费周期才会恢复——无限重试循环是一个错误。
未主动监控 X-RateLimit-Remaining。 如果 Remaining 降至10%以下 Limit,应在达到零前开始分散请求。大多数SDK不会自动执行此操作。额外的延迟仅为几毫秒,但好处是永远不会再看到429错误。
总结
429错误不是一次性的,你只需修复后就忘掉的问题。它是一个持续存在的约束,忽略伴随的响应头意味着你将不断撞上同样的墙。当API提供时,请使用 Retry-After 。当它没有提供时,请根据 X-RateLimit-Reset 计算。加入抖动以避免重试同步。设置上限,防止无限重试循环演变为生产事故。
当你盯着 X-RateLimit-Reset: 1716998400 并疑惑它何时真正到来时—— Unix 时间戳转换器 会告诉你答案。
