错误码与重试语义
不要一律重试。分类错了,故障转移反而让体验更差 —— 你等 15 秒才拿到一个本该 1 秒返回的 400。
我们内部怎么分类
| 类型 | 判据 | 我们的动作 |
|---|---|---|
| 连接前失败 | DNS / TCP / TLS / 连接超时 | 换渠道,你无感 |
| 首字节前失败 | 5xx、上游错误 JSON、200 但空流后断开 | 换渠道,你无感 |
| 限流 | 429 | 先在渠道内换账号,并收窄该账号的自适应并发窗口 |
| 配额耗尽 | 429 + 配额语义 | 该账号冷却到具体时间点(小时级),不是固定 30 秒 |
| 凭据失效 | 401 / 403(上游侧) | 禁用该账号并告警,不再重试该账号 |
| 客户端错误 | 400 / 413 / 422 / 上下文超长 | 绝不重试,直接返回给你 |
| 流中断 | 已输出内容后上游断开 | 仅在纯文本且已输出很少时尝试续写,否则结束 |
| 假成功 | 200 但内容异常(如 finish_reason 缺失) | 计入该渠道失败率,影响后续排序 |
400 绝不跨渠道重试
400 类错误意味着请求本身有问题:换一家商户结果一样,只是让你多等几秒。 我们会立刻把上游的错误原文按你的协议形状返回,你据此改请求即可。
容灾的边界:首字节
在第一个内容片段真正发给你之前,我们会先握住上游的响应。 这让「首 token 超时 / 空回复 / 静默拒绝」从不可恢复变成可以换渠道重试。
反过来的规则同样是硬的:已经向你写出语义字节之后,绝不再 failover。拼接两个上游的流会腐化对话内容,而你察觉不到 —— 那比直接报错严重得多。
- 首字节前失败 → 自动换渠道,你只感觉到「响应慢了几秒」
- 首字节后失败 → 流中断,我们不会用另一家的输出接上去
- 只写出 SSE 注释 / 心跳的情况不算「已输出」,仍可换渠道
你会看到的状态码
| 状态码 | 含义 | 建议动作 |
|---|---|---|
| 400 | 请求体不合法 / 上下文超长 | 修请求。重试无意义 |
| 401 | akrouter API Key 无效或已吊销 | 检查 key |
| 402 | 余额不足 | 充值。余额是硬门槛,不赊账 |
| 403 | 该 key 不允许调用这个模型 | 检查 key 的模型白名单 |
| 429 | 你触发了 key 的速率限制 | 按 Retry-After 退避。上游侧的 429 不会透给你,我们内部换账号处理 |
| 排队超时 | 所有候选渠道的并发槽位都满且排队超时 | 重试通常有效;持续出现说明该模型供给紧张 |
| 无可用渠道 | 成本护栏过滤后候选为空,或硬约束(requireNativeProtocol 等)无法满足 | 放宽上限或关闭对应硬约束 —— 我们不会替你突破它 |
| 5xx | 本站自身异常 | 重试;持续出现请看状态页 |
「无可用渠道」是设计的一部分
成本上限过滤后为空时,我们返回明确错误,不会静默突破你设定的上限。 你设的上限导致失败是可接受的;悄悄花掉超预算的钱不可接受。
错误响应形状
按你的客户端协议返回,不做统一封装。
json
// anthropic
{ "type": "error", "error": { "type": "invalid_request_error", "message": "..." } }
// openai_chat / openai_responses
{ "error": { "message": "...", "type": "invalid_request_error", "code": "..." } }