OmniRoute 连接故障归因:让 lastError 携带真实的上游传输错误而非模糊的 Provider error
在 OmniRoute 这种汇聚数百家上游 Provider 的 AI 网关中,一条连接(credential)因何失效直接决定了排障效率:是端口配错、网络被防火墙拦截、域名解析失败,还是上游代理被阻断,需要的处置手段完全不同。本篇围绕一次修复展开:markAccountUnavailable 持久化的 lastError 此前会把非字符串形态的上游错误一律塌缩为裸字符串 Provider error,而最关键的失败形态恰恰不是字符串——一次失败的 fetch 以 TypeError: fetch failed 到达,真正可操作的根因藏在 error.cause.code(如 ECONNREFUSED、ENOTFOUND、ETIMEDOUT)。文章将剖析该问题的根因、describeUpstreamFailure 的归因设计、与认证/配额链路的集成方式,以及它如何在不泄漏敏感请求内容的前提下,把 Dashboard 与控制台日志变成可定位的诊断入口。
问题场景:为什么"连接不可用"如此难以排查
网关级联请求上游时,任何一次底层 fetch 失败都会冒泡为异常对象。在修复之前,认证层对错误原因只做了一种极其粗糙的判读:
typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"
这段逻辑被测试 provider-error-detail-lastError.test.ts 明确验证为"必须移除"。它的缺陷在于:只有当错误内容本身已是字符串时才保留原样,其余所有形态一律退化为字面量 Provider error。随之而来的是严重的诊断混淆——端口配错、防火墙拦截、DNS 解析失败、代理被阻断这些成因完全不同的故障,在 Dashboard 的连接错误列与控制台日志行里呈现为完全相同的文本,运维者根本无法据此判断下一步动作。
Node.js 环境下一次失败 fetch 的典型形态并不是字符串,而是 TypeError: fetch failed,其可操作信息位于 error.cause.code:
const error = new TypeError("fetch failed");
error.cause = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:11434"), { code: "ECONNREFUSED" });
如果只读取 message,得到的是千篇一律的 fetch failed;只有读取 cause.code,才能分辨出是拒绝连接、域名不存在还是连接超时。
归因函数 describeUpstreamFailure 的设计
本次修复的核心是在 src/shared/utils/upstreamError.ts 新增 describeUpstreamFailure(value, fallback, maxLength),把任意未知输入规整为一行人类可读的上游失败原因。三个参数的作用与默认值如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
value |
无(必传) | 待归因的上游错误:Error 对象、Provider JSON body、字符串、任意对象均可 |
fallback |
"Provider error" |
完全无法提取信息时兜底返回的文本 |
maxLength |
100 |
输出单行长度的硬上限,超出截断 |
函数的分支处理顺序,决定了它对不同错误形态的容错能力:
- 字符串直接截断返回——字符串形态保持原有行为不变,仅受
maxLength约束; - 非对象(
null、undefined、数字等)返回兜底文本; - 提取传输层错误码:优先读取顶层的
code字段,其次深入error.cause.code,两者均有效时用cause的取值; - 提取消息正文:先调用复用的
extractErrorMessage解析标准 Provider JSON 形态,再尝试字符串化的error字段; - 消息与错误码合并去重:若
code尚未出现在消息中则追加(code)后缀;若消息为空但存在错误码,则以fallback (code)收尾; - 全部落空返回 fallback。
一个值得注意的细节是去重逻辑:connect ETIMEDOUT 10.0.0.5:443 这类把错误码嵌在 message 里的错误(错误码同时存在于顶层 code 字段),不会再被拼成 connect ETIMEDOUT 10.0.0.5:443 (ETIMEDOUT) 的冗余形态,参见测试 provider-error-detail-lastError.test.ts。
函数同时使用 clamp 对输出做归一化——把所有空白字符(含换行)折叠为单个空格后再截断到 maxLength,保证写入数据库/日志行的内容始终是干净的单行文本:
const clamp = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, maxLength);
多行 Provider 错误消息(如 "line one\nline two")因此会被折叠为 "line one line two",对应的行为锚定在 测试用例。
复用 extractErrorMessage:一套解析器覆盖 Provider 常见错误 JSON
describeUpstreamFailure 并非另起炉灶,而是复用了同文件内既有的 extractErrorMessage。该函数此前已被 toJsonErrorPayload 用来规范化上游错误 body,本次修复将其归因能力延伸到了 lastError。它按优先级读取以下 Provider 常见字段形态:
| Provider 常见返回形态 | extractErrorMessage 处理结果 |
|---|---|
{ "error": { "message": "model not found" } } |
model not found |
{ "message": "quota exceeded" } |
quota exceeded |
{ "error": "invalid api key" } |
invalid api key |
{ "detail": "no such deployment" } |
no such deployment |
{ "errors": [{ "message": "a" }, { "message": "b" }] } |
a, b |
{ "name": "..." } |
无 message/detail/errors 时兜底取 name |
其中 errors[] 数组会递归遍历每个元素:字符串条目直接取用,对象条目递归调用 extractErrorMessage 提取其 message,提取不到才回退为 JSON.stringify 输出,最后用 , 拼接。由于该函数只对对象输入返回有效结果(字符串输入返回 null),纯文本错误由 describeUpstreamFailure 的第一分支处理,两者职责边界清晰。
关键设计红线:错误对象绝不整体序列化
describeUpstreamFailure 的注释明确宣告了一条安全约束——只读取"消息形态"字段和传输错误码,绝不把整个错误对象序列化进存储原因:
const withPayload = {
code: "EPIPE",
request: { headers: { authorization: "Bearer sk-do-not-store" } },
};
describeUpstreamFailure(withPayload); // → "Provider error (EPIPE)"
如 安全用例 所验证:即便错误对象身上附着 request.headers.authorization,最终存储的 lastError 也只包含错误码文本,Bearer sk-... 与 authorization 字样均不会出现。这对于一个汇聚大量第三方凭据的网关而言是硬性要求——如果为图省事对整个 Error 做 JSON.stringify,请求体或请求头中的敏感凭据就可能被写入数据库并最终渲染到 Dashboard 上。
结合上述两条规则,describeUpstreamFailure 的完整输入输出映射可以整理为下面这张对照表(均为 upstreamError.ts 与 单元测试 可验证的行为):
| 输入 | 输出(含归因) | 依据 |
|---|---|---|
TypeError: fetch failed,cause.code=ECONNREFUSED |
fetch failed (ECONNREFUSED) |
测试第 32-35 行 |
cause.code=ENOTFOUND 的 fetch 失败 |
fetch failed (ENOTFOUND) |
测试第 32-35 行 |
message 内嵌错误码 connect ETIMEDOUT ... |
connect ETIMEDOUT 10.0.0.5:443(不重复拼码) |
测试第 37-40 行 |
{ code: "EAI_AGAIN" } 无消息 |
Provider error (EAI_AGAIN) |
测试第 53-55 行 |
{} / null / undefined / 42 |
Provider error |
测试第 57-63 行 |
附带 request.headers.authorization 的对象 |
Provider error (EPIPE)(不泄漏) |
测试第 65-74 行 |
| 多行 message | 折叠为单行 | 测试第 76-78 行 |
在认证与配额链路中的落地
describeUpstreamFailure 在 src/sse/services/auth.ts 的 markAccountUnavailable 中投入使用。该函数负责"连接不可用"状态的落库与指数退避管理,其内部执行了大量前置守卫(资源型 404 绕过、终结态保护、防惊群去重、Codex 作用域锁等),真正到达持久化阶段时,归因后的错误文本被写入 baseUpdate.lastError:
const errorMsg = describeUpstreamFailure(errorText);
const baseUpdate = {
lastError: errorMsg,
lastErrorType: providerErrorType,
errorCode: status,
lastErrorAt: new Date().toISOString(),
backoffLevel: newBackoffLevel ?? backoffLevel,
};
(见 src/sse/services/auth.ts。)同样的 errorMsg 还会进入控制台日志行,例如 Codex 作用域锁路径下的 ❌ ${provider} [${status}] (${scope}): ${errorMsg},让 CLI 侧与 Dashboard 侧共享同一份归因结果。
markAccountUnavailable 本身由多条上游调用链触发,从源码结构看它们都是本次修复的受益者:
- src/sse/handlers/chat.ts:流就绪失败等请求路径的标记入口;
- src/sse/handlers/chatHelpers.ts:凭证选择/401 守卫路径;
- src/lib/embeddings/service.ts:Embedding 服务路径;
- 配额与恢复模块如 src/lib/quota/connectionRecovery.ts 也围绕
markAccountUnavailable写入的 cooldown 状态做释放与探测。
归因文本最终经由 src/lib/db/providers.ts 的 UPDATE provider_connections 语句与读取逻辑,落到 last_error / last_error_at / last_error_type / last_error_source 这一组持久化字段上,供 Dashboard、健康检查与配额调度共同读取。
运维视角:这些错误码分别意味着什么
修复的价值最终体现在排障效率上。结合 Node.js 传输层错误码的常规语义,修复后 Dashboard 中的 lastError 大致能给出如下可直接行动的线索:
ECONNREFUSED(拒绝连接)——最常见的是目标端口错误或上游服务未监听,检查连接配置里的baseUrl端口;ENOTFOUND(域名解析失败)——DNS 层面的问题,域名拼写或 DNS 服务器配置需要核查;ETIMEDOUT(连接超时)——往往指向防火墙静默丢包、网络策略或上游负载过高;EAI_AGAIN(DNS 临时失败)——多为瞬时性 DNS 抖动,通常可依赖既有重试与退避策略;EPIPE(管道破裂)——常见于上游在响应中途断开连接。
结合 OmniRoute 的多账号退避机制,lastError 更清晰后,运维者可以更快判断"该账号是应进入冷却还是应人工处置":端口与 DNS 类错误通常属于配置问题,需要调整连接而非单纯等待退避;而 EAI_AGAIN 等瞬时错误则交给指数退避自然恢复即可。值得一提的是,markAccountUnavailable 同一函数内还维护着 backoffLevel(见 auth.ts),归因信息与冷却级别一起为路由调度提供输入。
总结
本次修复的本质,是把"账号不可用"这一布尔状态升级为带有根因属性的诊断信息:复用既有的 extractErrorMessage 解析 Provider 常见 JSON 错误体,新增读取传输层 cause.code 的能力,并用"消息 + (错误码)"的格式让 fetch failed (ECONNREFUSED) 这类可行动文本进入 Dashboard 与控制台,同时以"只取消息形态字段、绝不整体序列化错误对象"的设计守住凭据不泄漏的底线。整套逻辑由 provider-error-detail-lastError.test.ts 中的十余条用例锚定——从传输错误码保真、去重、多形态 JSON、裸码兜底到防泄漏与单行化——保证后续演进不会让诊断盲区回归。
相关文件索引:归因实现 src/shared/utils/upstreamError.ts;接入点与退避管理 src/sse/services/auth.ts;持久化字段 src/lib/db/providers.ts;行为契约测试 tests/unit/provider-error-detail-lastError.test.ts;变更记录 changelog.d/fixes/lasterror-provider-error-detail.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00