OpenRouter 实测:如何修复 OpenAI 与 Claude 的 429 限流错误
LLM API Rate Limits: How to Fix OpenAI and Claude 429 Errors
OpenRouter 发布教程,针对 OpenAI、Anthropic 等 API 返回的 429 限流错误给出三种修复方式:按 Retry-After 或 X-RateLimit-Reset 等待重试、用 models 参数回退到备用模型、让其他供应商承接同一模型。
429 Too Many Requests 错误意味着你在当前时间窗口内发送的请求或 token 超过了限额。OpenAI、Anthropic 和 OpenRouter 都会返回它,而且它通常会在应用首次承受真实负载、或批处理任务分散到多个 worker 时出现。
大多数人会选择重试,而在默认设置下这往往不够。当我们通过 OpenAI Python SDK 针对每分钟限额运行一个 60 条提示的批次时,SDK 重试之后仍有 23 个请求失败。在 SDK 放弃每个请求后 4 到 8 秒,窗口才重置。
本指南从一个真实的 429 出发,读取它的响应头,并用三种方式修复:在错误给出的重置时间重试、把溢出请求发给备用模型、让另一个提供商服务同一个模型。我们在 OpenRouter API 上运行了每个示例,每个代码块下方的输出就是我们得到的结果。
简而言之
-
429 意味着你在当前窗口内触发了限额。在 OpenRouter 上,error.metadata.limit_source 会告诉你这是谁的限额。
-
在默认设置下,OpenAI Python SDK 对每个 429 重试两次,并在约 2 秒内放弃。针对每分钟限额,我们批次中的 60 个请求仍有 23 个失败。
-
等待 Retry-After 或 X-RateLimit-Reset 的重试在每次运行中都完成了全部 60 个请求,Python 用时 89 到 92 秒,TypeScript 用时 106 到 218 秒。
-
models 备用方案在 13 秒内完成了全部 60 个请求,花费 $0.000066,且无需等待。
-
固定到一个提供商并关闭故障转移时,每次运行最多有 150 个请求中的 32 个失败。开启故障转移后,每次运行的全部 150 个请求都成功。
429 限流错误长什么样?
我们一次性向 poolside/laguna-s-2.1:free 发送了 40 个请求,这是一个限额为每分钟 20 个请求的免费模型。38 个成功,2 个从服务该模型的提供商返回了 429:
200: 38 429: 2 other: 0
429 limit_source: {'upstream_provider_shared_pool': 2}来自我们自己免费层限额的 429 带有不同的 limit_source。下面是另一次运行中的一个例子:
{
"message": "Rate limit exceeded: free-models-per-min. ",
"code": 429,
"metadata": {
"headers": {
"X-RateLimit-Limit": "20",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": "1792448640000"
},
"limit_source": "openrouter_free_tier_per_minute",
"remedy_hint": "Slow down requests to free models, or retry after the per-minute window resets.",
"provider_name": null
}
}响应头携带了你需要的数字。X-RateLimit-Limit 是上限,X-RateLimit-Remaining 是剩余量,X-RateLimit-Reset 是重试最早能成功的时间,以 Unix 毫秒时间戳表示。响应体增加了 limit_source,说明你触发了谁的限额,以及为阅读日志的人编写的 remedy_hint。在代码中根据 limit_source 分支,而不是根据提示文本。
OpenAI 和 Anthropic 用不同的名称发送相同的信息。OpenAI 的 x-ratelimit-reset-requests 是一个时长,例如 1s,而两个提供商都可以把 retry-after 作为秒数发送。
为什么限流会在不同的请求数下触发?
40 个请求中有 38 个通过了 20 的限额。限流器可以接受同一时刻到达的请求,而计数尚未跟上,并且大多数限流器不会在每分钟开始时干净地重置。Anthropic 使用持续补充的令牌桶。OpenAI 可以返回 429,代码为 slow_down,当你的流量增长过快时,即使你低于每分钟限额。
为了查看我们的免费模型限额如何计数,我们以不同模式又发送了 161 个请求。以下是其中三个:
| 测试 | 该分钟内成功数 |
|---|---|
| 空闲两分钟后的顺序请求 | 30 |
| 紧接着的那一分钟的顺序请求 | 30 |
| 30 个并发 | 29 |
由此得出两点。在我们测试的每一分钟里,都有超过 20 个请求成功,所以应把 20 视为 429 可能开始出现的临界点,而不是精确的计数。当我们在 poolside/laguna-s-2.1:free 上、窗口还剩 58 秒时触发该限制,发往 minimax/minimax-m3.1-flash:free 的五个请求立即通过了,而 Laguna 仍被阻塞。不过,这并不意味着上限是按模型计算的。OpenRouter 对一个账户下的所有免费模型应用同一个每分钟限流器,因此发往另一个免费模型的请求也可能得到同样的 429。
第 1 步:判断你触发了谁的限制
只有当限制按计划重置时,等待才能解决 429,所以在选择修复方案前先检查 limit_source:
| limit_source | 状态 | 谁的限制 | 如何修复 |
|---|---|---|---|
| openrouter_free_tier_per_minute | 429 | 我们的免费模型上限 | 等待 X-RateLimit-Reset(第 2 步),或回退到付费模型(第 3 步) |
| upstream_provider_shared_pool | 429 | 提供该模型的服务商 | 换另一个服务商(第 4 步)或回退模型(第 3 步) |
| openrouter_in_flight_budget | 402 | 你的在途支出预算 | 等待 Retry-After 后重试 |
| openrouter_key_limit、openrouter_credits | 402 | 你密钥的额度上限或你的余额 | 提高密钥的额度上限或充值 |

上游 429 意味着提供该模型的服务商容量不足。服务商不会说明容量何时释放,因此在我们多次运行中,这些错误都不带重置头。Gemma 4 31B 的免费版本返回了以下内容:
google/gemma-4-31b-it:free is temporarily rate-limited upstream. Please retry shortly, or add your own key to accumulate your rate limits.同一请求以付费版本作为回退,使用了另一家服务商的容量,并在 DeepInfra 上以 $0.0000037 成功。
有些看起来像限流的错误其实与费用有关。OpenAI 和 Anthropic 在达到每月支出上限时都会返回 429,在额度重置前重试也无法解决。在 OpenRouter 上,额度问题会以 402 返回。
第 2 步:按错误给出的重置时间重试
OpenAI 和 Anthropic 的 Python SDK 默认会对 429 重试两次,采用从 0.5 秒开始的短暂退避,且等待时间不超过 8 秒。当服务器发送 Retry-After 头时,它们会遵循该头。
我们的免费层 429 改为发送 X-RateLimit-Reset,而 SDK 不读取该头,因此两次重试都落在同一个已满的分钟里。以下是通过 OpenAI SDK(openai 3.26.1)以默认设置、每次 10 个、共 60 个提示词的批处理:
openai 3.26.1 | max_retries default: 2
finished in 12s (HTTP requests sent: 112)
succeeded: 37 RateLimitError after retries: 23
slowest failure gave up after 2.3s
message: Rate limit exceeded: free-models-per-min. | limit_source: openrouter_free_tier_per_minute
limit_source=openrouter_free_tier_per_minute x-ratelimit-reset: 1792444980000 retry-after: None window reset 8s after the SDK gave up
limit_source=openrouter_free_tier_per_minute x-ratelimit-reset: 1792444980000 retry-after: None window reset 7s after the SDK gave up
...
limit_source=openrouter_free_tier_per_minute x-ratelimit-reset: 1792444980000 retry-after: None window reset 4s after the SDK gave upSDK 为 60 个提示词发送了 112 个 HTTP 请求,仍有 23 个失败。提高 max_retries 最终能通过,但代价是每 8 秒就向一个已满的窗口发送请求,而且 OpenAI 的文档指出,失败的请求仍会计入你的每分钟限额。更好的重试方式是等到服务器给出的时间。要运行全部 60 个提示词,请使用一个当天至少还剩 60 次免费模型请求的账户,因为未购买 10 个额度的账户每天只有 50 次:
import email.utils
import os
import random
import time
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "https://openrouter.ai/api/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"}
def retry_delay(headers, attempt):
"""Seconds to wait after a 429: Retry-After, then X-RateLimit-Reset, then backoff."""
retry_after = headers.get("Retry-After")
if retry_after:
if retry_after.replace(".", "", 1).isdigit():
return float(retry_after)
when = email.utils.parsedate_to_datetime(retry_after).timestamp()
return max(0.0, when - time.time())
reset = headers.get("X-RateLimit-Reset") # Unix time in milliseconds
if reset:
# Spread clients over a few seconds after the reset, so they don't all retry at once.
return max(0.0, int(reset) / 1000 - time.time()) + random.uniform(1, 5)
return min(2 ** attempt, 60) * random.uniform(0.5, 1) # exponential backoff with jitter
def chat(body, max_attempts=6):
for attempt in range(max_attempts):
r = requests.post(URL, headers=HEADERS, json=body, timeout=120)
source = None
if r.status_code in (402, 429):
source = r.json()["error"].get("metadata", {}).get("limit_source")
if source == "openrouter_free_tier_daily": # resets at UTC midnight, so don't sleep until then
r.raise_for_status()
# A 402 from the in-flight budget clears after Retry-After; other 402s need credits.
if r.status_code != 429 and source != "openrouter_in_flight_budget":
r.raise_for_status()
return r.json()
if attempt == max_attempts - 1:
break # out of attempts, so don't wait before failing
wait = retry_delay(r.headers, attempt)
print(f"{r.status_code} ({source}), attempt {attempt + 1}: waiting {wait:.1f}s")
time.sleep(wait)
r.raise_for_status()
if __name__ == "__main__":
def job(i):
return chat({
"model": "poolside/laguna-s-2.1:free",
"messages": [{"role": "user", "content": f"Reply with the number {i}."}],
"max_tokens": 16,
})
t0 = time.time()
with ThreadPoolExecutor(max_workers=10) as pool: # the same batch job: 60 prompts, 10 at a time
results = list(pool.map(job, range(60)))
print(f"{len(results)} of 60 completed in {time.time() - t0:.0f}s")429 (openrouter_free_tier_per_minute), attempt 1: waiting 19.3s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 19.1s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 18.8s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 17.2s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 19.7s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 15.6s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 14.8s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 16.4s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 61.7s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 58.9s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 60.1s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 57.8s
60 of 60 completed in 89s全部 60 个请求都完成了,但这批请求耗时 89 秒,而不是 12 秒。这种方法不会失败,每个触发限制的请求都会等到窗口结束。
retry_delay 会先检查 Retry-After,因为它是服务器的直接指令,而 X-RateLimit-Reset 只标记窗口何时滚动。随机的 1 到 5 秒可避免 10 个工作线程在同一时刻重试、瞬间填满新窗口。
即便如此,本次运行中仍有四个请求在新窗口已经填满后才重试,因此它们又等待了一次。如果你像这样把 SDK 调用包在循环里,请在客户端设置 max_retries=0,否则你的每次尝试都会变成三个请求。
TypeScript 中的相同重试
retry.ts 遵循同样的顺序:先 Retry-After,再 X-RateLimit-Reset,最后退避。它使用内置的 fetch,因此不需要任何包。我们在 Node 24 上用 npx tsx retry.ts 运行了它:
const URL = "https://openrouter.ai/api/v1/chat/completions";
const HEADERS = {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const randomBetween = (min: number, max: number) => min + Math.random() * (max - min);
/** Seconds to wait after a 429: Retry-After, then X-RateLimit-Reset, then backoff. */
export function retryDelay(headers: Headers, attempt: number): number {
const retryAfter = headers.get("Retry-After");
if (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds)) return seconds;
const when = Date.parse(retryAfter); // HTTP date
if (!Number.isNaN(when)) return Math.max(0, (when - Date.now()) / 1000);
}
const reset = headers.get("X-RateLimit-Reset"); // Unix time in milliseconds
if (reset) {
// Spread clients over a few seconds after the reset, so they don't all retry at once.
return Math.max(0, (Number(reset) - Date.now()) / 1000) + randomBetween(1, 5);
}
return Math.min(2 ** attempt, 60) * randomBetween(0.5, 1); // exponential backoff with jitter
}
export async function chat(body: object, maxAttempts = 6): Promise<any> {
let res: Response | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
res = await fetch(URL, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (res.status !== 402 && res.status !== 429) {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
const error = (await res.json()).error;
const source = error?.metadata?.limit_source;
// A 402 from the in-flight budget clears after Retry-After; other 402s need credits.
if (res.status === 402 && source !== "openrouter_in_flight_budget") {
throw new Error(`HTTP 402: ${JSON.stringify(error)}`);
}
if (source === "openrouter_free_tier_daily") {
throw new Error(`HTTP 429: ${JSON.stringify(error)}`); // resets at UTC midnight, so don't sleep until then
}
if (attempt === maxAttempts - 1) break; // out of attempts, so don't wait before failing
const wait = retryDelay(res.headers, attempt);
console.log(`${res.status} (${source}), attempt ${attempt + 1}: waiting ${wait.toFixed(1)}s`);
await sleep(wait * 1000);
}
throw new Error(`Still rate limited after ${maxAttempts} attempts`);
}
// The same batch job: 60 prompts, 10 at a time.
async function main() {
const prompts = Array.from({ length: 60 }, (_, i) => i);
const results: any[] = [];
const worker = async () => {
for (let i = prompts.shift(); i !== undefined; i = prompts.shift()) {
results[i] = await chat({
model: "poolside/laguna-s-2.1:free",
messages: [{ role: "user", content: `Reply with the number ${i}.` }],
max_tokens: 16,
});
}
};
const t0 = Date.now();
await Promise.all(Array.from({ length: 10 }, worker));
const completed = results.filter(Boolean).length;
console.log(`${completed} of 60 completed in ${Math.round((Date.now() - t0) / 1000)}s`);
}
main();429 (openrouter_free_tier_per_minute), attempt 1: waiting 34.9s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 34.6s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 35.1s
429 (openrouter_free_tier_per_minute), attempt 1: waiting 33.5s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 60.5s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 58.6s
429 (openrouter_free_tier_per_minute), attempt 2: waiting 60.6s
60 of 60 completed in 106s总耗时取决于批次开始时窗口所处的位置,以及模型提供方的繁忙程度。我们的两次 TypeScript 运行分别耗时 218 秒和 106 秒,两次 Python 运行分别耗时 92 秒和 89 秒。四次运行都完成了每一个请求。
第 3 步:将溢出请求发送到备用模型
如果第二个模型的回答可以接受,你根本不需要等待。提供方的速率限制只针对某个提供方上的某个模型,因此不同的模型使用独立的容量。免费额度上限是例外,因为你账户上的每个 :free 模型都共享它。使用 models 参数时,我们会按顺序尝试每个模型,并在遇到任何错误(包括 429)时切换到下一个,在请求到达你的代码之前:
import os
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "https://openrouter.ai/api/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"}
def job(i):
r = requests.post(URL, headers=HEADERS, timeout=120, json={
# Try the free model first; if it is rate limited, OpenRouter sends the request to the next one.
"models": ["poolside/laguna-s-2.1:free", "deepseek/deepseek-v4.1-flash"],
"messages": [{"role": "user", "content": f"Reply with the number {i}."}],
"max_tokens": 16,
})
data = r.json()
return r.status_code, data.get("model"), data.get("usage", {}).get("cost", 0)
t0 = time.time()
with ThreadPoolExecutor(max_workers=10) as pool: # the same batch job: 60 prompts, 10 at a time
results = list(pool.map(job, range(60)))
print(f"finished in {time.time() - t0:.0f}s")
print(Counter((status, model) for status, model, _ in results))
print(f"total cost: ${sum(cost for *_, cost in results):.6f}")finished in 13s
Counter({(200, 'poolside/laguna-s-2.1:free'): 52, (200, 'deepseek/deepseek-v4.1-flash'): 8})
total cost: $0.000066免费模型处理了 52 个请求,付费模型处理了另外 8 个。没有任何请求等待,你只需为付费的那些付费。响应的 model 字段记录了是哪个模型作答,也就是你付费的模型。
选择一个拥有独立容量的备用模型。你账户上的每个 :free 模型都共享免费模型上限,因此当上限就是原因时,另一个免费模型并不是可靠的备用选择。免费模型也经常耗尽提供方容量,所以付费备用模型是更可靠的选择。
以下是同一批次的三次运行并排对比:
| 运行 | 完成数 | 时间 | 成本 |
|---|---|---|---|
| OpenAI SDK,默认重试 | 60 个中的 37 个 | 12 秒 | $0 |
| 等待重置的重试 | 60 个中的 60 个 | 89 秒 | $0 |
| 模型备用 | 60 个中的 60 个 | 13 秒 | $0.000066 |
如果任务可以等待,就在重置时重试。如果不能等待,并且第二个模型的回答足够好,就添加备用模型。
第 4 步:让另一个提供方服务同一个模型
第 2 步和第 3 步处理了我们的免费模型上限。付费模型会遇到另一种限制:正在服务它们的提供方的容量。OpenRouter 上的大多数模型由多个提供方服务,每个提供方都有自己的速率限制。当一个提供方返回 429 时,我们会在错误到达你之前,在下一个提供方重试该请求。这是默认开启的。
我们向 google/gemma-4-31b-it 发送了 300 个请求,每次 50 个,并检查了每个请求的 GET /api/v1/generation 记录中的 provider_responses 列表,它显示了我们尝试过的每一个提供方:
300 requests, 50 at a time, in 11s
status codes: Counter({200: 300})
generation records read: 300 of 300
served by: {'CoreWeave': 177, 'DeepInfra': 118, 'Novita': 1, 'Venice': 1, 'ModelRun': 1, 'Parasail': 2}
requests that needed more than one provider: 0
failed attempts, by provider and status: []
total cost: $0.00130分布在六个提供方上,没有任何请求需要第二次尝试。然后我们用 “provider”: {“order”: [“crusoe”]} 将同一个模型固定到 Crusoe,并发送了 150 个请求,每次 50 个。以下是开启备用时的一次运行:
150 requests, 50 at a time, in 5s
status codes: Counter({200: 150})
generation records read: 150 of 150
served by: {'CoreWeave': 79, 'Crusoe': 71}
requests that needed more than one provider: 79
failed attempts, by provider and status: [(('Crusoe', 429), 79)]
total cost: $0.00056Crusoe 对 79 个请求进行了速率限制,CoreWeave 回答了全部 79 个,客户端看到了 150 次成功。我们在关闭和开启 allow_fallbacks 的情况下多次重复了固定提供方的运行:
| allow_fallbacks | 运行次数 | 每次运行成功数 | 每次运行 429 数 |
|---|---|---|---|
| false | 4 | 150, 147, 118, 136 | 0, 3, 32, 14 |
| true | 3 | 150, 150, 150 | 0 |
Crusoe 的容量每分钟都在变化,因此关闭备用时的失败数从零到 32 不等。开启备用时,Crusoe 在三次运行中分别对 21、41 和 79 个请求进行了速率限制,而客户端从未看到其中任何一个。任何缩小可服务请求的提供方范围的做法,都会减少吸收突发流量的容量。如果你在 OpenRouter 上仍然看到提供方 429,请检查是否有 allow_fallbacks: false、过短的 only 列表或过紧的 max_price。要在不失去故障转移的情况下偏好某个提供方,请设置 order 并将 allow_fallbacks 保留为默认值。
同样向 anthropic/claude-haiku-5.5 和 openai/gpt-6-luna 突发发送 300 个请求,没有出现失败尝试。当闭源模型自身的 API 容量耗尽时,错误通常是 529 而不是 429,Claude 529 过载指南对此有说明。
如何完全避免触发速率限制?
上述修复方法针对的是 429 发生后的处理。以下习惯可以降低其发生概率:
-
将并发数限制在限额允许的范围内。并发正是让我们第一次测试超限的原因。将请求节奏控制在略低于限额,比在 429 后等待整个窗口结束所损失的时间更少。
-
将 max_tokens 设置得接近你预期的答案长度。OpenAI 会将 max_tokens 计入你的每分钟 token 限额,因此设置过大会比答案本身更快地耗尽限额。
-
在大型任务前检查你的密钥。GET /api/v1/key 会报告每日免费模型计数器。在这些运行之后,我们的读数为 {'used': 585, 'limit': 1000, 'remaining': 415}。在你购买至少 10 个 credits 之前,每日限额为 50 次免费模型请求,之后为 1,000 次。
-
在生产环境中使用付费变体。每分钟 20 次的上限仅适用于 :free 模型。
常见问题
如何提高我的 OpenAI API 速率限制?
当你的组织累计购买的 credits 超过每个使用层级阈值时,OpenAI 会自动提高你的限额。你可以在 OpenAI 仪表板的 Settings > Organization > Limits 下查看当前限额。通过 OpenRouter,提供商路由会将模型的流量分散到所有为其提供服务的提供商,因此单个提供商的限额不会限制你。
为什么 Claude 提示“rate limit reached”?
在 Claude API 上,这意味着你的组织用完了该模型的每分钟请求数或 token 数,retry-after 头会说明需要等待多少秒。如果 429 来自你的每月支出上限,则没有 retry-after 头,在访问恢复之前重试也无济于事。在订阅版 Claude Code 中,该消息通常意味着套餐的使用限额。参见达到 Claude Code 使用限额?。
更多 API 密钥能绕过速率限制吗?
不能。在 OpenRouter 上,额外的 API 密钥或账户不会改变你的速率限制,因为我们全局管理容量。提供商限额按模型适用,这就是回退模型有效的原因,但你账户上的每个 :free 模型共享一个免费层级上限。使用 BYOK 时,使用你自己提供商密钥的请求会计入你在该提供商的限额。
Retry-After 的单位是秒还是毫秒?
秒,或一个 HTTP 日期。我们的 X-RateLimit-Reset 不同。它是毫秒级的 Unix 时间戳。第 2 步中的 retry_delay 函数处理所有三种形式。
后续步骤
-
限额:免费模型上限、X-RateLimit-* 头以及如何处理 402 和 429 错误。
-
提供商路由:order、allow_fallbacks、only 和排序。
-
模型回退:models 参数以及回退请求如何计费。
-
免费 LLM API 对比:各提供商的免费层级限额。
来源:OpenRouter Announcements · openrouter.ai