首页
/ OmniRoute 连接故障归因:让 lastError 携带真实的上游传输错误而非模糊的 Provider error

OmniRoute 连接故障归因:让 lastError 携带真实的上游传输错误而非模糊的 Provider error

2026-09-07 13:48:07作者:滕妙奇

在 OmniRoute 这种汇聚数百家上游 Provider 的 AI 网关中,一条连接(credential)因何失效直接决定了排障效率:是端口配错、网络被防火墙拦截、域名解析失败,还是上游代理被阻断,需要的处置手段完全不同。本篇围绕一次修复展开:markAccountUnavailable 持久化的 lastError 此前会把非字符串形态的上游错误一律塌缩为裸字符串 Provider error,而最关键的失败形态恰恰不是字符串——一次失败的 fetchTypeError: fetch failed 到达,真正可操作的根因藏在 error.cause.code(如 ECONNREFUSEDENOTFOUNDETIMEDOUT)。文章将剖析该问题的根因、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 输出单行长度的硬上限,超出截断

函数的分支处理顺序,决定了它对不同错误形态的容错能力:

  1. 字符串直接截断返回——字符串形态保持原有行为不变,仅受 maxLength 约束;
  2. 非对象(nullundefined、数字等)返回兜底文本
  3. 提取传输层错误码:优先读取顶层的 code 字段,其次深入 error.cause.code,两者均有效时用 cause 的取值;
  4. 提取消息正文:先调用复用的 extractErrorMessage 解析标准 Provider JSON 形态,再尝试字符串化的 error 字段;
  5. 消息与错误码合并去重:若 code 尚未出现在消息中则追加 (code) 后缀;若消息为空但存在错误码,则以 fallback (code) 收尾;
  6. 全部落空返回 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 行

在认证与配额链路中的落地

describeUpstreamFailuresrc/sse/services/auth.tsmarkAccountUnavailable 中投入使用。该函数负责"连接不可用"状态的落库与指数退避管理,其内部执行了大量前置守卫(资源型 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/lib/db/providers.tsUPDATE 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

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388