OpenRouter 报错怎么定位:官方 errors 页把错误分成了哪几层
接入 OpenRouter 之后最容易踩的坑,不是某个具体错误码,而是在错误的那一层做判断。很多人的错误处理写成 switch (res.status),跑一阵子发现分支越加越多、还老是落到 default 里。翻完官方 openrouter.ai/docs/api_reference/errors-and-debugging 这一页会发现,它其实把错误信息分层摆出来了,状态码只是最外面那一层,而且是信息量最少的一层。
这篇按排查的顺序走:拿到一个报错先看哪儿、怎么确认落在哪一层、每一层文档给的处置动作是什么、处置完怎么验证,最后说清楚什么情况根本不是错误——这一步最容易被跳过,也最容易让人白折腾半天。
现象:状态码是 200,但请求失败了
典型的三种表现:
一是流式请求返回 200 OK,客户端却只收到半截内容,最后一个 SSE 事件里带着错误。二是同一类失败,在 Chat Completions 上看到一个码,换到 Responses API 上看到的是 server_error,换到 Anthropic Messages 上看到的是 api_error——像是三个不同的问题。三是 500 的 message 是一句没有信息量的通用串,你想 grep 关键字做分支,怎么写都对不上。
这三种表现指向同一个根因:你在读的字段不是那一层该读的字段。
第一层:HTTP 状态码只在两种情况下等于错误码
官方文档写明,错误响应的 JSON 形状是:
type ErrorResponse = {
error: {
code: number;
message: string;
metadata?: Record<string, unknown>;
};
};
关键的一句在紧接着的正文里:HTTP 响应的状态码与 error.code 相同,但只在两种情况下会形成这种「请求错误」——你的原始请求无效,或者你的 API key / 账户信用金不足。除此之外,返回的 HTTP 状态是 200 OK,在 LLM 产出输出过程中发生的任何错误,都会出现在响应体里或者作为 SSE data 事件发出来。
所以判定动作很直白,文档给的 JavaScript 示例就是干这个的:
const request = await fetch('https://openrouter.ai/...');
console.log(request.status); // Will be an error code unless the model started processing your request
const response = await request.json();
console.error(response.error?.code); // Will be an error code
console.error(response.error?.message);
注意那句注释的措辞——「除非模型已经开始处理你的请求」。这句话就是分层的分水岭:模型一旦开始产出,状态码这条通道就废了。
文档在 Error codes 一节列出了 8 个状态码及其含义,这里只挑排查时最容易混的两个说:502 是你选中的模型挂了、或者 OpenRouter 从它那里收到了无效响应;503 是没有任何满足你路由要求的模型 provider。两者的下一步动作完全不同:前者是模型侧的可用性问题,后者是你自己的路由条件把候选集筛空了,文档在 Limits 页对 503 给的建议是放宽 provider 偏好或者加 fallback 模型。把这两个混成「服务端出错,重试一下」,503 重试一百次也还是 503。
第二层:error_type 才是你该 switch 的字段
这是整页的核心。文档原话是:OpenRouter 把每一个上游 provider 错误都归一化成稳定的、带类型的 error_type 词表;无论这个错误是从非流式响应体里来的,还是作为流中 SSE 事件来的,同一套 error_type 值描述同一件事。而原生协议码(Anthropic 的 error.type、Responses 的 error.code)是尽力而为的,在不同格式之间可能不一样——error_type 才是跨格式可以依赖的字段。
error_type 的词表在文档里按语义分成了 7 组:token 与长度限制、认证与授权、限流与可用性、请求校验、内容策略、图片错误、通用。每一组对应的下一步动作是不同的:
| 分组 | 代表 error_type | 这一组该做什么 |
|---|---|---|
| 认证与授权 | authentication / permission_denied / payment_required | 不要重试,重试多少次都一样,去查 key 与余额 |
| 限流与可用性 | rate_limit_exceeded / provider_overloaded / provider_unavailable | 退避重试;文档说明 provider_unavailable 在开启 fallback 路由时 OpenRouter 可能自动换 provider 重试 |
| 请求校验 | invalid_request / invalid_prompt / not_found / payload_too_large 等 | 改请求,重试无意义 |
| token 与长度 | context_length_exceeded / max_tokens_exceeded / token_limit_exceeded / string_too_long | 改输入或输出上限,注意下面讲的「被转成成功」那一条 |
| 内容策略 | content_policy_violation / refusal | 改内容,不是故障 |
| 通用 | server / timeout / unmapped | unmapped 时去看 provider_code |
这张表只列了排查时最常撞上的几行,完整词表与每个值对应的 HTTP 状态在官方那一页上,建议直接对着抄进你的枚举。
error_type 出现的位置随 API skin 变,这是最容易写错的地方。文档写明 OpenRouter 暴露三个 API skin,各自的落点是:
- Chat Completions:
error.metadata.error_type - Anthropic Messages:
error.error_type(在error对象里面,和原生的error.type并排) - Responses:顶层
error_type,在原生error对象外面
文档也解释了为什么要多加这个字段(这是文档自述):原生的 error.type 是有损的,很多内部类型会塌陷。Anthropic skin 上 provider_unavailable、server、unmapped 会一起塌成 api_error;Responses skin 上 authentication、provider_overloaded、provider_unavailable、timeout、server 会一起塌成 server_error。文档给的例子很说明问题——一次认证失败,原生码是 server_error,但 error_type 仍然是 authentication:
{
"id": "resp_abc123",
"status": "failed",
"error": { "code": "server_error", "message": "Invalid credentials" },
"error_type": "authentication"
}
如果你按原生码分支,这个 key 失效的问题会被你归进「服务端偶发故障」,然后你会重试到天黑。
第三层:provider_code 与 openrouter_metadata
error_type 告诉你是哪一类,provider_code 告诉你上游原话是什么。文档写明:非 500 错误时,上游 provider 自己的错误码在可用的情况下会出现在 error.metadata.provider_code。
而 500 是个例外,也是一条必须记住的规则:请求以 500 失败时,message 会被替换成一个通用字符串,provider_code 和 openrouter_metadata 都会被省略,但 error_type 仍然存在(值为 server)。文档给的理由是防止泄漏上游细节。这条规则的直接后果是:永远不要对 error.message 做字符串匹配来分支,500 上你匹配的那句话根本不会出现。
最里面一层是 openrouter_metadata,携带路由上下文——请求了哪个模型、用了什么策略、尝试了第几次、是不是 BYOK、候选 endpoint 情况,以及一个 pipeline 数组。它是 opt-in 的,需要在请求上带 header 才会返回。
这里有个口径需要提醒:同一页文档里,讲 guardrail 403 的那一节写的 header 是 X-OpenRouter-Experimental-Metadata: enabled,而讲遮蔽与原始 provider 细节的那一节写的是请求设置 X-OpenRouter-Metadata。两处写法不一致,我们无法判断哪一个是当前生效的,落地前请以官方文档最新内容为准。另外,名字里带 Experimental 的那一个字面上就是实验性的,别把它当稳定接口写进核心链路。
第四层:pre-stream 与 mid-stream 是两码事
这条是正交于上面所有分层的一条轴,也是流式接入最容易翻车的地方。
Pre-stream 错误:任何 token 都还没发出去时,HTTP 响应尚未提交,所以 OpenRouter 可以返回正经的 4xx/5xx 状态、可以在开启 fallback 路由时静默换一个 provider endpoint 重试、可以在开工前做限流与鉴权检查。无效 key、请求格式错误、所有可用 endpoint 在开流前就被耗尽,都属于这一类。
Mid-stream 错误:第一个 token 一旦写给客户端,200 OK 和响应头就已经提交、改不了了。文档明确写:这时候 OpenRouter 无法静默故障转移到另一个 provider,因为部分内容已经交付给你的应用了。错误只能带内传递,走 SSE 事件。文档列了 5 类常见成因:provider 断连、provider 超时、生成过程中撞到 token 上限或上下文被填满、输出内容过滤器在已经吐出一部分之后判定拦截、provider 过载。
mid-stream 错误的 SSE 结构有几个判定特征,逐条可查:错误对象在顶层、和标准响应字段并排;error.metadata.error_type 带归一化码;choices 数组会带一条 finish_reason: "error" 用来正常终止流;HTTP 状态保持 200 OK;这个事件之后流就终止了。
判定动作:不要只判断 HTTP 状态和流是否读完。解析每一个 SSE chunk 时,检查顶层有没有 error 字段,以及 choices[0].finish_reason 是不是 "error"。少了这一步,你的应用会把一个失败的生成当成正常的短回答。
非流式请求同样会遇到这个问题。文档给了 Chat Completions 上的形状:provider 错误会和已产出的部分内容一起嵌在最终响应里——
{
"choices": [{
"message": { "role": "assistant", "content": "partial output..." },
"finish_reason": "error",
"error": {
"code": 502,
"message": "Provider disconnected mid-stream",
"metadata": { "error_type": "provider_unavailable" }
}
}]
}
也就是说,choices[0] 里有 message.content 不代表这次调用成功了。
处置后怎么验证
限流与余额,用 GET /api/v1/key 自查。 官方 Limits 页把限制分成两类:credit limits 管你能花多少(超了报 402),rate limits 管你能发多少请求(超了报 429)。前者的自查入口是 GET /api/v1/key 响应里的 limit_remaining,后者是错误响应上的 X-RateLimit-* 头。文档给的 Python 示例原样如下:
import requests
import json
response = requests.get(
url="https://openrouter.ai/api/v1/key",
headers={
"Authorization": f"Bearer <OPENROUTER_API_KEY>"
}
)
print(json.dumps(response.json(), indent=2))
返回体里排查时用得上的字段:limit(该 key 的信用金上限,null 表示不限)、limit_reset(重置类型,null 表示不重置)、limit_remaining、include_byok_in_limit(外部 BYOK 用量是否计入该上限),以及按全时 / 当日 / 当周 / 当月分的 usage 系列。文档把响应里的 rate_limit 对象标注为 deprecated,说明可以安全忽略,别再往它上面写逻辑了。
Windows 侧的密钥别硬编码进脚本。以下是通用 shell 用法,不是 OpenRouter 官方文档内容:PowerShell 里当前会话设 $env:OPENROUTER_API_KEY = "<YOUR_API_KEY>",CMD 里用 set OPENROUTER_API_KEY=<YOUR_API_KEY>;Linux / macOS 用 export OPENROUTER_API_KEY=<YOUR_API_KEY>。然后在代码里从环境变量读。这一段与产品无关,纯粹是别把 key 提交进仓库。
限流的两个来源要分清。 文档写明 429 可能来自 OpenRouter 平台限制(免费变体的请求上限、DDoS 防护),也可能来自上游 provider 的限流或满负荷。区分方式是看 error.metadata.provider_code——上游来的会在可用时带上它的原始码。文档还说明了一条容易忽略的细节:成功的推理响应不带 X-RateLimit-* 头,只有 OpenRouter 自己因平台限制返回 429 时,错误响应才带 X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset。所以指望从正常响应头里读配额是读不到的。
429 和 503 上,OpenRouter 可能带标准的 Retry-After 响应头。文档说明 OpenAI SDK、Anthropic SDK、Vercel AI SDK 和 OpenRouter SDK 已经会尊重这个头做退避;直接用 fetch 的要自己处理:
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { ... });
if (res.status === 429 || res.status === 503) {
const retryAfter = Number(res.headers.get('Retry-After'));
if (Number.isFinite(retryAfter) && retryAfter > 0) {
await new Promise((r) => setTimeout(r, retryAfter * 1000));
// retry the request
}
}
怀疑是参数被改写,用 debug。 文档提供了一个 debug 选项,形状是 { echo_upstream_body?: boolean },开了之后能看到实际发给上游 provider 的请求体。三条限制必须记住:只在 stream: true 下生效,非流式请求会忽略这个参数;文档明确警告不要用在生产环境,它可能返回你并不想暴露的敏感信息,仅供开发调试;用了 provider fallback 时,每一个被尝试的 provider 都会发一个 debug chunk,这一条恰好是排查「到底试了哪几个 provider、分别发了什么参数」最直接的手段。
403 要再分一层。 文档说明 403 可能是权限不足、guardrail 拦截,或者被 moderation 标记,三者的 error.metadata 形状不同:moderation 会带 reasons、flagged_input(被标记的文本片段,超长会在中间截断并替换成省略号)、provider_name、model_slug;guardrail 拦截则带 patterns 这类描述拦截原因的字段。还有一条定位线索藏在 Anthropic Messages skin 的非流式错误信封里:文档写明 pre-Router 错误(例如认证失败)的 request_id 是 null,post-Router 错误带 gen- 开头的生成 ID。request_id 是不是 null,直接告诉你这次失败发生在路由之前还是之后。另外,在 /messages 上被 guardrail 拦截时,返回的是 Anthropic 的 permission_error 信封,不是 OpenRouter 形状的错误体。
以上代码片段均照抄官方文档,未经实测;组合使用时以官方文档与实际返回为准。
什么情况说明不是这个原因
排查到这一步还没定位,先检查是不是撞上了下面这几种「看起来像故障、其实不是」的情况。
一、模型没产出内容,但这不是错误。 文档写明模型偶尔不生成任何内容,典型原因是模型正从冷启动预热,或者系统正在扩容以承接更多请求;预热时间通常从几秒到几分钟,取决于模型和 provider。这种情况下你拿不到 error_type,因为压根没有错误对象。文档建议做一个简单的重试,或者换一个近期活跃度更高的 provider 或模型。还有一条得提前知道:这种情况下你仍可能被上游 provider 按 prompt 处理成本计费。
二、Responses API 上,一部分「错误」被转成了成功。 文档给了一张转换表:context_length_exceeded、max_tokens_exceeded、token_limit_exceeded、string_too_long 这 4 个 error_type,在 Responses API 上会被转成成功响应,finish_reason 为 length。文档自述这是为了让基于长度限制的错误能被优雅处理、而不被当成失败。后果是:你的错误处理分支根本不会被触发。 如果你在排查「为什么输出总是被截断却查不到任何错误」,去成功路径里查 finish_reason 是不是 length,别在错误处理里找。
三、error_type 是 unmapped,说明分类本身没兜住。 文档对这个值的描述是:上游错误没有映射到任何已知分类,error.metadata.provider_code 可能带着原始码。这时候继续在 error_type 上加分支是没用的,得去看 provider_code。
四、状态码对上了,但 skin 不对。 如果你在 Responses API 上找 error.metadata.error_type、或者在 Chat Completions 上找顶层 error_type,会一直取到 undefined,然后所有分支都落到 default。这不是服务端的问题,是取值路径写错了层。Responses API 的流式错误还会以三种 SSE 事件类型之一出现——response.failed、response.error、error,最后一种是为了匹配上游 OpenAI 的行为,你的 SSE 处理器如果只认其中一种,另外两种就会被静默丢掉。
最后提一句判断顺序上的经验:拿到报错,先问「HTTP 状态是不是 200」定位是 pre-stream 还是 mid-stream,再按 skin 找到 error_type 的正确位置,最后才在需要区分上游细节时去看 provider_code。倒过来查——从 message 的文案开始猜——在 500 上必然扑空,因为那句话已经被替换掉了。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
文中涉及的字段名、请求头与调试选项随版本变动,请以官方文档最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。