OpenRouter 429 速率限制触发了怎么办:机制与退避策略
数据截至 2026-08,价格与限额以各官网为准。本文只讲计费与接入机制,不列具体价格数字。
OpenRouter 的 429 有两个完全不同的来源,处理方式也完全不同:一种是你撞到了平台自己的限制(免费模型变体的每分钟/每天请求上限,或者 Cloudflare 的 DDoS 防护),另一种是真正给你干活的那家上游供应商在限流或满载。前者只能退避等待或者调整用量结构,后者其实 OpenRouter 已经替你换过供应商重试了,错误能传到你手上说明所有候选都没跑通。区分它们的字段是 error.metadata.provider_code——上游给的原始错误码会放在这里;判断该等多久的依据是 Retry-After 响应头,但这个头只在特定条件下才出现。再往前一步,很多人以为的”限流”其实是额度问题,那是另一个错误码,退避多少次都没用。
先看错误体:429 长什么样
官方文档里给出的 429 错误体是这个形状:
{
"error": {
"code": 429,
"message": "Rate limit exceeded",
"metadata": {
"error_type": "rate_limit_exceeded"
}
}
}
在这类请求级错误上,error.code 与 HTTP 状态码是一致的(模型已开始产出后才出的错走另一套,见下文流式部分)。真正值得你写进代码的是 metadata.error_type——官方明确建议用这个值而不是单看 HTTP 状态码来做程序化的错误分类,因为它在三套 API 外观(Chat Completions、Anthropic Messages、Responses)之间是稳定的,而各家原生协议的错误码在不同格式之间可能对不上。rate_limit_exceeded 在官方的类型表里被描述为”请求级或 token 级的限流已触发,重试前请尊重 Retry-After 头”。
error_type 在不同外观下的位置不一样,这点很容易踩:Chat Completions 放在 error.metadata.error_type,Anthropic Messages 放在 error.error_type,Responses 则是顶层的 error_type。你要是照着 Chat Completions 的路径去解 Anthropic 外观的响应,就会拿到 undefined,然后把限流误判成未知错误。
429 的两个来源,分开处理
官方在限额一章里把 429 的来源拆成两条:
第一条来自 OpenRouter 平台本身。触发的是两类平台级限制:免费模型变体的每分钟请求数与每天请求数上限,以及 Cloudflare 的 DDoS 防护——后者会拦截明显超出合理用量范围的请求。免费变体的那两档上限按历史累计购买额度分档,购买量达到一定程度后每日请求上限会提高,具体分档数值请以官方限额页为准。
第二条来自 上游供应商。给你这次请求实际提供推理的那家在限流或者容量打满了。这种情况下有两件事已经发生了:一是供应商自己的原始错误码会被放进 error.metadata.provider_code(能拿到的时候),二是如果你开着 fallback 路由,OpenRouter 会先自动重试同一模型的其他供应商,都失败了错误才会到你这里。
这个差别决定了你的应对完全不同。平台侧的 429 是账户级别的、跟你用哪家供应商无关,重试其他供应商没有意义;供应商侧的 429 说明这个模型当前的可用容量已经被你的路由配置榨干了,此时更应该做的是放宽路由约束或者加模型级兜底,而不是原地死等。
响应头才是判断依据
这一段是官方文档里干货密度最高的地方,也是最容易被忽略的:
- 成功的推理响应不带
X-RateLimit-*头。所以你不能靠”每次请求都读一下剩余配额”来做预算——正常返回的时候压根没有这些头。 - 当 OpenRouter 自己因为平台级限制返回 429 时,错误响应会带上
X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset三个头,描述的是你刚刚撞到的那条限制。 Retry-After头有一个明确的出现条件:当所有被尝试过的供应商都返回了重试提示时,错误响应才会带上它。
第三条的限定词很关键。它不是”429 一定有 Retry-After”,而是”每一个尝试过的供应商都给了重试提示”才有。所以你的重试逻辑必须写成两条腿:有这个头就按它说的秒数等,没有就退回到自己的指数退避。官方给的 fetch 版示例里,判断条件是状态码为 429 或 503 时去读 Retry-After,把秒数乘以 1000 转成毫秒后再 sleep,值要先校验是不是有限正数——因为这个头可能缺失,也可能不是数字。
如果你用的是 OpenAI SDK、Anthropic SDK、Vercel AI SDK 或者 OpenRouter 自家 SDK,官方说明这几个 SDK 已经会尊重这个头做退避,你不需要重复实现;只有直接用 fetch 裸调的时候才要自己处理。
想在撞线之前就知道自己还剩多少,官方给的办法是主动调 GET /api/v1/key。这个接口返回的对象里有 limit、limit_reset、limit_remaining 描述单个 key 的额度上限,还有 usage、usage_daily、usage_weekly、usage_monthly 四个用量字段——分别是全时段、当前 UTC 日、当前 UTC 周(周一起算)、当前 UTC 月。注意时间口径都是 UTC,跟你本地的”今天”不是一回事,做日报表的时候这里最容易错位。响应里还有一个被官方标为已废弃的 rate_limit 对象,明确说明可以安全忽略,别再照着它写逻辑。
流式请求里的 429 藏在别的地方
这是最容易漏掉的一种形态。如果限流是在流已经开始之后才触发的,HTTP 状态码 200 和响应头早就发出去了,改不了。官方的说法是:这时候错误只能以 SSE 事件的形式在流里送达,finish_reason 是 "error",而不是一个 HTTP 429。
事件长这样(官方示例,模型名与 id 为示意):
data: {"id":"cmpl-abc123","object":"chat.completion.chunk","created":1234567890,"model":"openai/gpt-4o","provider":"openai","error":{"code":429,"message":"Rate limit exceeded"},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
几个特征值得记住:错误对象出现在 chunk 的顶层,跟 id、object、created 这些字段并排,不在 choices 里面;choices 数组仍然会给一个 finish_reason: "error" 的元素,目的是让流能被正常终结;HTTP 状态保持 200;这个事件发出后流就结束了。
官方还解释了为什么中途出错就不能自动换供应商:第一个 token 一旦写给客户端,部分内容已经交付出去了,OpenRouter 再切到别的供应商会造成内容拼接错乱,所以只能把错误在带内抛给你。反过来说,只要是在任何 token 写出之前出的错,哪怕是流式请求,OpenRouter 依然可以透明地换后备供应商重试。这条区分很实用:你在客户端看到的”流跑了一半断了”,和”请求根本没起来”,在平台侧是两套完全不同的处理路径。
对应到代码,官方给的错误处理范式是双层的:try/catch 只能兜住流开始之前的错误,流开始之后必须在遍历 chunk 的循环里判断 'error' in chunk,命中就打印错误并结束。Python 版则是先检查 response.status_code 是否为 200 来区分预流错误,再逐行解析。只写 try/catch 不检查 chunk 的实现,会把中途限流当成正常结束的短回复处理掉——这种 bug 在日志里几乎看不出来。
别把额度问题当成限流
OpenRouter 明确把限制分成两类,只有一类归 429 管:
- 额度限制(Credit limits):管你能花多少,来自账户余额和 key 上可选的消费上限两处,超了报的是额度不足那个错误码,查询位置是
GET /api/v1/key的limit_remaining。 - 速率限制(Rate limits):管你能发多少请求,也就是免费模型的请求上限和 DDoS 防护,查询位置是错误响应上的
X-RateLimit-*头。
这两者混淆的代价是浪费大量重试。额度类的错误退避多少次都不会自己好,官方给的处理顺序是:先充值把账户余额补到零以上,再检查单 key 的 limit_remaining 是否耗尽(耗尽就提高该 key 的上限或者等 limit_reset),最后是主动调 GET /api/v1/key 提前监控。有个反直觉的点值得单独记:账户余额为负的时候,请求可能失败,而且免费模型也在波及范围内,把余额补回零以上才能重新使用它们。你如果只用免费模型、突然全线报错,先查余额而不是查限流。
更细的通用分层可以看限流指标 RPM 与 TPM 的区别和429 错误的通用处理思路,那两篇讲的是跨厂商都成立的机制,本文只讲 OpenRouter 的具体形态。
结构性的解法:两层兜底
光靠退避只能扛住瞬时抖动。持续撞 429 的话,官方给的解法是把兜底做进请求本身,分两层:
供应商层,用请求体里的 provider 对象。allow_fallbacks 默认为真,含义是主供应商不可用时允许换其他符合条件的供应商;把它设成 false 就变成主供应商失败即报错。order 指定按顺序尝试的供应商列表,only 限定只允许哪些,ignore 指定跳过哪些。这里有个陷阱:only 或 order 收得太窄,会让可路由的 endpoint 数量急剧减少,供应商侧一限流就无处可退。官方在讲 429 的处理时也直说了,遇到供应商侧限流应该”放宽供应商路由偏好,让更多供应商有资格承接这次请求”。
顺带说一下默认路由是怎么挑的,理解它才知道收窄的代价有多大。官方描述的默认负载均衡策略是三步:先排除近期出现过明显故障的供应商(官方文档写的是最近 30 秒的窗口,以官方当前版本为准),然后在稳定的候选里按价格的倒数平方加权随机选一个,剩下的作为后备。所以默认状态下你本来就有一串后备可用,一旦设了 sort 或 order,负载均衡会被关闭,路由改为按你指定的顺序尝试。
模型层,用顶层的 models 参数,传一个按优先级排列的模型 ID 数组。第一个模型报错时会自动尝试下一个。官方明确列出了会触发模型级 fallback 的情形,其中就包含限流,另外还有上下文长度校验失败、被过滤模型的审核标记、以及宕机。计费上,请求按最终实际使用的那个模型计价,用了哪个会在响应体的 model 字段里返回——所以做兜底之前先想清楚备选模型的价位,别把降级配成了升价,具体单价见官方定价页。
用 Anthropic Messages 接口的话,对应参数叫 fallbacks,形状与 Anthropic SDK 一致,每项只接受 model 一个字段,写 max_tokens、thinking 这类逐次覆盖会被 400 拒绝;而且 fallbacks 不能和 models 同时使用,两个都传也是 400。官方还特别注明,这个 fallback 是 OpenRouter 自己做的路由,不是 Anthropic 的服务端 fallback 功能。
多开账号和多开 key 不解决问题
这条官方写在限额章开头的提示框里,值得单独拎出来:额外注册账号或者多建 API key 不会改变你的限流状况,因为容量是全局管控的。官方给的替代思路是——不同模型的限流额度是分开的,所以可以通过分散到不同模型来摊掉压力。
对免费模型变体,官方给出的两条正路是:把累计购买额度提上去以抬高每日上限,或者换成该模型的付费变体——付费变体没有平台级的请求数上限。至于要充到什么程度才跳档,那是会变的数值,以官方限额页为准。想先摸清免费变体的边界,可以看OpenRouter 免费模型的使用规则。
最容易栽的三个坑
第一,把 429 当成一种错误来重试。平台限流和供应商限流混在一个 catch 分支里,前者需要降速、后者需要换路由,用同一套退避处理的结果是两边都不对。先读 provider_code 有没有值,再决定走哪条路。
第二,只在 HTTP 层做错误处理。流式场景下限流会以 200 状态 + SSE 事件的形式出现,不检查 chunk 顶层的 error 字段就等于没处理,用户看到的是一段莫名其妙截断的回复。
第三,把额度耗尽当限流退避。账户余额为负可能让免费模型也一起失败,这时候看起来像”全线限流”,实际上是钱的问题;退避重试只会白白拖慢整个任务。排鉴权和额度类问题的顺序,可以参考OpenRouter 报错 401 的排查顺序。
真要动手改代码,建议的顺序是:先补上 Retry-After 的读取与回退退避,再补流式的 chunk 错误检查,最后才考虑加 models 兜底——前两项是纠正错误的处理逻辑,第三项是提升可用性,顺序反了容易在没修好判断的前提下把问题掩盖掉。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。