首页
/ OmniRoute 提供商故障转移(Provider Failover)指南:失败分类、重试策略与熔断状态机

OmniRoute 提供商故障转移(Provider Failover)指南:失败分类、重试策略与熔断状态机

2026-09-07 12:22:58作者:鲍丁臣Ursa

导读

在 OmniRoute 这类聚合 350+ 提供商、上千个模型的 AI 网关中,单次请求可能途经多个上游服务,任何一个上游抖动都可能被放大为整条链路的失败。本文基于仓库文档 docs/OMNIROUTE_PROVIDER_FAILOVER.md,系统讲解 OmniRoute 的提供商故障转移机制:失败如何先分类再决定重试、跨提供商尝试的上限与默认策略、以及 closed / open / half_open 三态熔断与受限探测的工作原理。读完本文,你将理解 OmniRoute 如何在「避免对鉴权失败、无效请求等确定性错误盲目重试」与「对超时、限流、5xx 等瞬时错误做快速转移」之间取得平衡,并能结合源码定位熔断阈值、冷却时间与重试预算的配置入口。


一、故障转移的第一原则:失败分类先于重试决策

OmniRoute 故障转移机制的核心原则是:所有失败在进入重试决策之前,必须先行分类。分类的目的不是给错误贴标签,而是为了避免两类典型误判:

  • 把瞬时错误当永久错误——导致一次超时就让整个提供商被永久跳过;
  • 把永久错误当瞬时错误——导致对密钥失效、模型不存在等确定性错误反复重试,白白烧掉配额与时间。

1.1 可以故障转移的失败(瞬时/可恢复)

文档明确给出可触发故障转移的失败集合:

  • 超时(timeouts):请求在规定时间内未得到响应;
  • 网络错误(network errors):如连接被拒、上游不可达;
  • 限流(rate limits):上游明确返回 HTTP 429 或等价信号;
  • 上游 5xx 响应(provider 5xx):上游服务端自身的临时故障。

在源码层面,这一集合被精确建模。看 src/sse/handlers/chatPredicates.ts 中的 PROVIDER_BREAKER_FAILURE_STATUSES

export const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);

export function isProviderBreakerFailureStatus(status: number): boolean {
  return PROVIDER_BREAKER_FAILURE_STATUSES.has(Number(status));
}

HTTP 408(请求超时)、500(内部错误)、502(坏网关)、503(服务不可用)、504(网关超时) 这五类状态码才会被计为熔断器可统计的提供商故障。该集合用于判定「全部连接被限流/故障时,是否把该提供商推入熔断冷却」,是文档所述原则的直接落地。

1.2 不盲目重试的失败(确定性错误)

与上述瞬时错误相对,以下失败不会被盲目重试

  • 鉴权错误(authentication errors):如 API Key 失效、OAuth Token 过期;
  • 权限错误(permission errors):账户无权限访问目标模型或接口;
  • 无效请求(invalid requests):请求体、参数、格式本身不合法;
  • 模型不可用(unavailable models):请求的模型不存在或已下线;
  • 未知失败(unknown failures):无法归类的错误,保守起见不重试。

1.3 排除「本地错误」:避免用户操作级故障污染提供商状态

值得一提的是,src/shared/utils/circuitBreaker.ts 中提供了两个本地错误甄别函数,用于把「网关自身生命周期问题」与「上游故障」分开,防止一次本地事件级联触发整个提供商熔断:

  • isLocalStreamLifecycleError(error):识别本地流的 Invalid state: Controller is already closed、客户端主动断开(request_signal_abortedAbortErrorClient disconnected)等。这类错误带不上游状态码,默认会被当作 502 计入熔断——而实际上问题出在 OmniRoute 自身的桥接层或客户端,不是上游。
  • isLocalExecutionError(error):识别本地宿主执行错误(ENOENTEACCESEPIPEcommand not found 等)。

这两个函数通过熔断器选项 isFailure: (e) => !isLocalStreamLifecycleError(e) 注入到提供商熔断器中(见下文源码调用链),从机制上防止「一个用户的取消操作把整个提供商列入冷却名单」。


二、HTTP 429 的深挖:限流与配额耗尽的语义拆分

HTTP 429 是所有 LLM 提供商最常返回的错误,但它背后隐藏着两种语义截然不同的情况,这正是分类器的用武之地:

语义 含义 修复手段 错误分类
rate_limit(限流) 短时并发过高("too many requests in the last minute") 等待 Retry-After 窗口后重试 短暂冷却 + 可重试
quota_exhausted(配额耗尽) 长周期额度见底(每日/每月限额) 等待计费周期滚动(可能数小时/数天) 长冷却,禁止短循环重试

HTTP 状态码本身无法区分这两种情况——同一厂商可能用 429 同时表达两者。OmniRoute 为此实现了专门的 429 分类器 src/shared/utils/classify429.ts,其类型定义如下:

export type FailureKind = "rate_limit" | "quota_exhausted" | "transient";

classify429 采用以下决策顺序(源码注释原文总结):

  1. 状态码不是 429 → 一律判为 "transient"
  2. 响应体命中终态信用/账单信号INSUFFICIENT_G1_CREDITS_BALANCEcredit exhaustout of creditsbilling cap 等)→ 直接判 "quota_exhausted",即便伴随任何重试提示,因为这类问题不靠计时器恢复;
  3. 上游声明了小于 1 小时的明确重试窗口(如 Google Gemini 的 RetryInfo.retryDelay、文本 please retry in 38.92s)→ 判 "rate_limit",即使正文同时命中配额关键词——上游既然说几秒后恢复,就不该套用小时级的长锁定;
  4. 正文命中通用配额关键词(daily/monthly limitquota exceed 等 30+ 条正则)→ 判 "quota_exhausted"
  5. 兜底 → 判 "rate_limit"

同时它还提供 parseRetryAfter(headerValue) / retryAfterFromResponse(response) 解析 Retry-After 头,兼容纯整数秒、HTTP 日期(Wed, 08 May 2026 03:00:00 GMT)以及 Groq 风格相对单位(60s5m2h)。classify429FromError(err) 则负责从 fetch/axios 风格的错误对象中抽取 status / headers / body 后复用同一套分类,作为熔断器 classifyError 回调的适配器。

一个值得注意的工程细节:分类器还内置了「429 必须显式命中配额关键词才判为耗尽」的保守策略,避免把上游正常的每分钟限流误判为长期配额问题。


三、跨提供商尝试策略与重试预算

3.1 默认跨提供商策略

文档给出了默认策略的三条边界:

  1. 最多三次提供商尝试(up to three provider attempts);
  2. 对限流与超时执行重试(retries rate limits and timeouts);
  3. 管理性禁用与临时熔断状态严格分离(keeps administrative disablement separate from temporary circuit state)。

第三条意味着:运维人员手动停用的连接(管理性意图,通常记录在连接/账户配置里)与熔断器因连续失败自动产生的冷却(临时状态,由故障统计驱动)走的是两条独立的数据通道,不会互相污染、也不会互相覆盖。

3.2 冷却感知的重试(Cooldown-Aware Retry)

对「全部连接都处于冷却/限流」的场景,OmniRoute 实现了冷却感知重试服务 src/sse/services/cooldownAwareRetry.ts,其内置硬上限如下:

const MAX_REQUEST_RETRY = 10;          // 单请求最多重试次数
const MAX_RETRY_INTERVAL_SEC = 300;    // 单次等待最长 300s(5 分钟)
const MAX_BUDGET_MS = 5 * 60 * 1000;   // 整个请求累计等待预算 5 分钟

getCooldownAwareRetryDecision() 的判定逻辑包含四道闸门:关闭开关、maxRetries 用完、Retry-After 无法解析、或单次等待超过 maxRetryWaitMs / 累计超过 budgetMs——任一命中即放弃重试。budgetMs 是跨多次重试的累计预算(在 src/sse/handlers/chat.ts 中声明为 requestRetryBudgetLeftMs,随每次等待递减),防止「每次最多等 X 秒,但反复重试 N 次」导致单请求总等待失控。

waitForCooldownAwareRetry() 则负责实际睡眠,同时监听 AbortSignal:客户端断连时立即中止等待并返回 false,调用方据此回 499(Request aborted)而非继续空等。

3.3 请求重试循环的源码证据

src/sse/handlers/chat.ts 的请求处理核心中可以看到双层重试结构(源码第 1559 行起的 requestAttemptLoop):

  • 外层循环负责凭据/连接维度的切换——被排除的连接记录在 excludedConnectionIds 集合中,失败后重新拉取下一批候选凭据;
  • 内层循环负责单条连接上的配额预检与重试
  • 当返回 allRateLimited 时,进入冷却感知重试判定:若 retryDecision.shouldRetry,等待 Retry-Aftercontinue requestAttemptLoop 重新开始请求,日志形如 COOLDOWN_RETRY provider/model cooldown elapsed — restarting request attempt 2/10

同时,熔断相关状态变更会经 onStateChange 输出为 CIRCUIT 日志(name: CLOSED → OPEN),便于线上观测状态机流转。


四、熔断器状态机:closed → open → half_open 与受限探测

4.1 文档定义的三态模型

docs/OMNIROUTE_PROVIDER_FAILOVER.md,熔断器存在三种状态:

  • closed(关闭):正常运行,请求照常放行;
  • open(打开):短路,请求被直接拒绝,不再尝试该提供商;
  • half_open(半开):冷却到期后进入的受限状态,只允许有界探测请求进入。

探测规则是:一次成功的探测把熔断器拉回 closed,一次失败的探测则把它重新打回 open。整个流程由一个**冷却计时器(cooldown)**驱动:冷却期内的状态是确定的 open,冷却到期后才调度探测,避免「一到期就全量放行」造成雪崩。

4.2 仓库实现:四态状态机与自适应退避

仓库实际实现 src/shared/utils/circuitBreaker.ts 在此基础上扩展出更细粒度的状态机:

CLOSED → DEGRADED → OPEN → HALF_OPEN → CLOSED

对应枚举 STATE = { CLOSED, DEGRADED, OPEN, HALF_OPEN },语义为:

状态 行为 进入条件
CLOSED 正常放行 初始态 / 探测成功 / 手动复位
DEGRADED 放行但记录告警 失败数达到 failureThreshold 的 60%(默认 degradationThreshold
OPEN 短路拒绝请求 失败数达到阈值 / 特定失败类型触发 immediateOpen / 探测失败
HALF_OPEN 允许 halfOpenRequests 个探测 OPEN 冷却到期(timeout-elapsed

默认构造参数(src/shared/utils/circuitBreaker.tsCircuitBreaker 构造函数):

this.failureThreshold   = options.failureThreshold ?? 5;          // 触发 OPEN 的失败阈值
this.resetTimeout       = options.resetTimeout ?? 30000;          // 基础冷却 30s
this.halfOpenRequests   = options.halfOpenRequests ?? 1;          // 半开期仅允许 1 个探测
this.degradationThreshold = ... Math.ceil(failureThreshold * 60 / 100); // 降级阈值 = 阈值的 60%

自适应退避是状态机的关键增强:每次经历 OPEN → HALF_OPEN → OPEN(探测失败)循环,openCycleCount 递增。超过 backoffEscalationCount(默认 3 次)后,冷却时间按 2 的幂放大,但不超过 resetTimeout * maxBackoffMultiplier(默认 16 倍封顶):

const escalationFactor = Math.pow(2, this.openCycleCount - this.backoffEscalationCount);
return Math.min(this.resetTimeout * escalationFactor, this.resetTimeout * this.maxBackoffMultiplier);

这一设计直接呼应了文档所述「失败的探测重新打开熔断器」——而反复的失败探测会让重新打开的冷却逐级拉长,对持续故障的上游形成指数退避保护。

4.3 失败类型感知的冷却

熔断器支持 cooldownByKindkindThresholds,即不同失败类型可映射到不同冷却时长。在 src/sse/handlers/chat.ts 第 1515 行附近,提供商熔断器的实际接线如下:

const breaker = getCircuitBreaker(provider, {
  failureThreshold: providerProfile.failureThreshold,
  resetTimeout: providerProfile.resetTimeoutMs,
  isFailure: (e) => !isLocalStreamLifecycleError(e),      // 本地流错误不计数
  onStateChange: (name, from, to) => log.info("CIRCUIT", `${name}: ${from}${to}`),
  ...(useHints429 ? {
    cooldownByKind: {
      rate_limit: 60_000,          // 限流类:60s 冷却
      quota_exhausted: 3_600_000,  // 配额耗尽类:1 小时冷却
    },
    classifyError: classify429FromError,   // 复用 429 分类器
  } : {}),
});

这展示了第 2 节所述分类器的下游价值:同一熔断器把 rate_limitquota_exhausted 分别映射到 60 秒1 小时的冷却,避免「配额耗尽却每 60 秒重试一次」的无效空转。

4.4 状态持久化与管理性禁用分离

熔断器状态会通过 saveCircuitBreakerState / loadCircuitBreakerState(对应 src/lib/db/domainState.ts持久化到数据库,重启后按需恢复(构造函数中 _restoreFromDb())。每条记录保存当前状态、失败计数、最近失败时间、失败类型及各类型计数、openCycleCount 等。

这正是文档「把管理性禁用与临时熔断状态分开」的实现保障:

  • 临时熔断状态由故障统计自动驱动、自动过期,是可被探测/成功恢复的瞬时量,存于熔断器状态记录;
  • 管理性禁用由运维显式操作产生,属于连接/账户配置层面的决策,不随故障计数衰减,也绝不因一次成功探测而被清除。

两者互为独立信号,路由决策层分别消费,互不覆盖。熔断器还内置了注册表容量治理MAX_REGISTRY_SIZE = 500,每 5 分钟清理一次空闲 CLOSED 熔断器;当注册表满时优先淘汰「最久无故障的 CLOSED」实例,永不主动淘汰 OPEN / HALF_OPEN 实例(它们携带有效状态)。CircuitBreakerOpenError 携带 retryAfterMs,让上层可以把「熔断中」转译成可消费的重试时间。


五、哪里可以调优:熔断与重试的配置入口

熔断与重试参数最终由 src/lib/resilience/settings/types.ts 中定义的 ResilienceSettings 承载,其核心子配置块:

export interface ProviderBreakerProfileSettings {
  failureThreshold: number;      // 触发 OPEN 的失败次数阈值
  degradationThreshold: number;  // 进入 DEGRADED 的阈值
  resetTimeoutMs: number;        // OPEN → HALF_OPEN 的基础冷却(毫秒)
}

export interface ProviderCooldownSettings {
  minRetryCooldownMs: number;    // 失败后最小冷却,默认 5000ms
  maxRetryCooldownMs: number;    // 冷却硬上限,默认 300000ms(5 分钟)
  enabled: boolean;              // 全局冷却开关,默认 true
}

export interface WaitForCooldownSettings {
  enabled: boolean;
  maxRetries: number;            // 单请求重试次数(硬上限 10)
  maxRetryWaitSec: number;       // 单次等待上限(硬上限 300s)
  budgetMs: number;              // 全请求累计等待预算(硬上限 5 分钟)
}

值得注意两点工程约束:

  • ProviderCooldownSettings.minRetryCooldownMs随失败次数指数放大minRetryCooldownMs * 2^(failures-1)),但被 maxRetryCooldownMs 封顶,防止提供商被无限期跳过——这是「跨提供商故障转移」在连接冷却维度的配套机制;
  • providerBreakerconnectionCooldown 均按鉴权类别(oauth / apikey)拆分配置,说明熔断策略可以针对「订阅账号型」与「API Key 型」提供商分别设定不同的激进程度。

另外,健康检查与熔断共享同一套运维哲学:后台凭据健康检查(见 src/lib/monitoring/providerHealthAutopilot.tssrc/lib/monitoring/providerHealthMatrix.ts)负责在请求之外主动探测凭据与提供商状态,与熔断器互为补充——前者发现未病先治,后者在病时兜底短路。仓库根目录另有姊妹篇文档 docs/OMNIROUTE_ROUTING_POLICY.mddocs/OMNIROUTE_QUOTA_TELEMETRY.md 分别覆盖路由打分与配额遥测,可与本文的故障转移话题联动阅读。


六、总结

OmniRoute 的提供商故障转移机制可以归纳为一条清晰的责任链:

  1. 分类:429 响应经由 classify429 拆分为 rate_limit / quota_exhausted,其余错误按状态码落入瞬时(408/5xx)或确定性(鉴权、权限、无效请求等)类别;
  2. 决策:瞬时错误进入跨提供商尝试与重试流程(默认最多三次提供商尝试;冷却感知重试受次数、单次等待、累计预算三重约束),确定性错误直接终结;
  3. 隔离:每个提供商由 CircuitBreaker 守护,在 CLOSED → DEGRADED → OPEN → HALF_OPEN 状态间流转,冷却到期后仅放行有界探测(默认 1 个),探测成功关闭熔断、失败则重新打开并指数拉长退避;
  4. 持久与分离:熔断状态持久化于数据库,可跨进程恢复;管理性禁用与临时熔断状态各自独立,成功探测只能关闭临时熔断,绝不越权清除管理性决策。

这套设计回答了一个 AI 网关的终极问题:当上游不可靠时,如何在「尽快恢复」与「不乱烧配额」之间作出可解释、可调优、可观测的取舍——先分类、再重试、用熔断兜底,正是 OmniRoute 故障转移的全部要义。

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