Skip to content

fix(openai-ws): WS 入口对齐 HTTP 的传输失败换号与账号准入等待 - #7438

Closed
xlplbo wants to merge 4 commits into
Wei-Shaw:mainfrom
xlplbo:fix/openai-ws-transport-failover-admission-wait
Closed

xlplbo wants to merge 4 commits into
Wei-Shaw:mainfrom
xlplbo:fix/openai-ws-transport-failover-admission-wait

Conversation

@xlplbo

@xlplbo xlplbo commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

目的

OpenAI 平台的 HTTP 入口在“上游失败”和“账号满槽”两类情况下已有成熟策略:传输失败换号并冷却、按状态码分类换号与同账号重试、首输出超时换号上限、按调度器给出的等待计划排队等槽。WebSocket 入口(Codex 默认使用)没有接上其中大部分——同样的故障,HTTP 请求能换号或排队后完成,WS 连接被直接关闭,Codex 重试 5 次后退回 HTTP。

本 PR 把 WS 入口补到与 HTTP 一致。不新增策略、不新增配置项、不改 HTTP 既有行为:错误分类、冷却时长、重试次数、等待超时与队列上限全部复用 HTTP 现有的函数与配置。

解决的问题

# 场景 HTTP 现状 WS 现状(本 PR 前) 本 PR
1 未收到 HTTP 状态码的传输失败(代理断开、拒连、DNS、超时) handleOpenAIUpstreamTransportError:换号;持久性故障首次即临时禁调度 10 分钟 ctx_pool 拨号超时回客户端 1013 busy,不换号、不冷却;http_bridge 只有首轮走该入口,后续轮直接写错误帧 ctx_pool 拨号失败与 http_bridge 各轮复用同一入口
2 首轮握手被状态码拒绝(401/403/429/5xx) shouldFailoverOpenAIUpstreamResponse 分类后换号或同账号重试 以 1011 关闭客户端,不换号 首轮且无 previous_response_id 时复用 HTTP 的错误分类、账号状态与重试规则
3 首输出超时换号次数 maxOpenAIFirstOutputTimeoutSwitches = 1 只受总换号预算(默认 10)约束 共用同一上限
4 换号预算的生命周期 每个请求独立 每条连接只建一次:长连接前几轮用完后整条连接不能再换号,失败账号一直留在排除集 成功完成一轮后重置(账号自身冷却不受影响)
5 换号重选账号所用的模型 当前请求的模型 连接内允许后续轮换模型,但换号重选仍用首轮模型过滤账号 用当前轮模型(经渠道映射)
6 账号并发槽已满 调度器返回 AccountWaitPlan,按 sticky_session_wait_timeout(120s)/ sticky_session_max_waiting(3)或 fallback(30s / 100)排队 调度器同样给出了等待计划,但 WS 不执行,立即 1013 account is busy 复用同一等待计划、同一等待计数键、同一退避循环

真实流量里的表现

数据来自一个自用测试环境:统计期内 4–5 个 OAuth 账号(下文涉及的记为 A–D)各并发 3,Codex CLI / Desktop 真实使用,白天每小时 30–44 个 WS 会话外加同量级 HTTP。

场景 1:账号 B 的出口代理断开约 1 小时。 11.5 小时窗口内客户端可见失败 138 次,其中 126 次是“拨号超时后回客户端 1013 busy”,全部来自账号 B。粘性会话只在账号不可调度时解绑,而 WS 拨号失败既不换号也不冷却,于是 6 个 API key 的会话被反复送回这个死账号:B 被选中 156 次、0 次成功,同组另外 4 个账号全程健康。去掉这一段,其余 11 小时只有 4 次失败。同一时刻 HTTP 路径遇到同样的错误会换号。

场景 2:首轮握手被拒。 3 天内握手被 403 拒 23 次、503 拒 11 次(均为首轮),34 次全部以 1011 upstream websocket proxy failed 关闭客户端、没有换号。其中一段 13 秒内同账号 9 次 403,响应头为 server: cloudflare、带 cf-ray、无 x-request-id(请求未到 OpenAI),十几秒后同账号自行恢复——这类瞬时拒绝正是 HTTP 侧“同账号重试 / 换号”要覆盖的情形。

场景 6:同一账号同一时刻,HTTP 能等、WS 不能。

时间 事件
17:00:08 某账号上一条 WS 的上一轮完成并释放槽
17:00:10 同一连接的下一轮抢不到槽,收到 1013 account is busy, please retry later
— Codex 重试 1/5 … 5/5 后记录 falling back to HTTP
17:00:17 回退后的 HTTP 请求选中同一个账号
17:00:27 进入转发(选号到转发约 9.9 秒,其中多少属于排队无法从日志区分),17:00:48 以 200 完成

两条入口共用账号并发槽,但忙时准入策略不同:WS 连接被踢掉,客户端重连 5 次、退回 HTTP,最后还是在同一个账号上完成。被踢还有额外代价:重连后若落到别的账号,那一轮的未命中输入均值约 3.4 万 token(留在原账号约 1.3 万;样本 12,738 轮,其中换账号 396 轮混有共用会话标识的子智能体,仅作量级参考)。

效果

账号满槽时的去向(续聊轮排队上线后的 16 小时窗口,3,038 个完成轮次;容量被刻意压到 2 个可调度账号共 6 个槽以制造争用):

需要等槽的请求 次数 等到槽 队列满被拒 等待超时 客户端离开 成功等待耗时 p50 / p95
连接内后续轮 667 615 52 0 — 2.4 s / 37.8 s
连接首轮 162 143 — 17 2 3.2 s / 40.9 s

按本 PR 之前的行为,满槽即立即 1013 关闭。现在其中 758 次原地等到了槽(两种行为下的争用动态不同,这个数不能直接读成“挽回的连接数”);52 次被拒全部是等待队列已满(sticky_session_max_waiting = 3,与 HTTP 共用),日志里 account is busy 与 queue_full 一一对应。

为什么透传的续聊轮也必须能排队。 准入等待最初只覆盖首轮与非透传模式,上线后反而放大了踢连:排队者一拿到槽,正在对话的老会话下一轮就抢不到槽被 1013 关闭(每条 wait_finished acquired 之后 1–2 秒必跟一条同账号的 account is busy)。补上透传续聊轮排队后回落:

阶段 样本小时 续聊轮被 1013 account is busy 关闭 完成轮次 每百轮关闭数
本 PR 前(无等待,同上游现状) 两个白天小时 13 / 11 368 / 321 3.5 / 3.4
只让首轮与非透传排队 两个白天小时 423 / 409 783 / 700 54.0 / 58.4
本 PR(含透传续聊轮排队) 两个晚间高峰小时 24 / 18 789 / 935 3.0 / 1.9

所以提交 3 把两者放在一起,中间形态不单独提交。透传续聊轮的帧在准入前一直由网关持有、尚未写入上游,排队期间上游到客户端的读取由独立 goroutine 继续(pong 照常),因此等待是安全的。

口径与混杂因素(请一并阅读)
  • 这不是同负载 A/B:三个阶段的时段、流量与可用容量都不同。中间阶段那两个小时同时叠加了配额自动暂停(4 个账号里最差时只剩 1 个、3 个槽在扛约 30 个会话),踢连数被容量不足进一步放大;因此只应读出“方向与数量级”,不应读成精确收益。
  • “上游现状”阶段首轮被拒不写日志,只能用续聊轮关闭数对比;首轮的收益见上一张表的 143 次。
  • 第三阶段所用的构建同时包含 fix(openai): 非高级调度补齐选号决策并按 previous_response 路由 #7427 的非高级调度修复,与等待逻辑无代码耦合,但属于同批部署。
  • 改后每百轮关闭数(3.0 / 1.9)与上游现状(3.5 / 3.4)接近,差别在于:现状下每一次满槽都是关闭,改后只有队列满才关闭,其余 615 + 143 次等到了槽。

传输失败换号(场景 1)改后: 18 小时窗口内 58 个请求各换号 1 次,全部从故障账号 B 切出(A / C / D 接替 6 / 24 / 28 次),57 个换号后正常产生用量,客户端没有再收到 1013。B 的故障表现为 SOCKS 连接失败,属瞬态类:按 HTTP 既有分类只换号、不冷却,60 秒内 7 次失败也没有触发任何禁调度——与 HTTP 行为一致。

改动

前三个提交按依赖顺序排列,可逐个评审(每个提交都能独立编译并通过自身测试);第四个是评审后的修复:

  1. WS 拨号与桥接后续轮传输失败复用 HTTP 换号入口
    • 新增 handleOpenAIWSDialTransportFailure:仅当“拨号阶段 + 未收到 HTTP 状态码 + 非客户端取消”同时成立时,转交 handleOpenAIUpstreamTransportError,返回 UpstreamFailoverError 让 handler 换号。有状态码的拒绝不走这里。
    • http_bridge:doOpenAIUpstream 失败分支由“仅首轮”放宽为 OpenAI 账号的所有轮次;进入共用入口前先检查抢占标记与 ctx.Err(),避免已结束的会话给账号写入冷却。
    • 后续轮换号错误携带当前轮请求:此前后续轮换号会用保留的首包重放,导致当前轮丢失、首轮重复生成并计费(已用两轮会话复现,见测试)。
  2. WS 换号对齐 HTTP 的预算、模型、握手重试与首输出超时(对应上表 2–5)
    • handleOpenAIWSHandshakeFailure 直接调用 HTTP 的 shouldFailoverOpenAIUpstreamResponse / handleOpenAIAccountUpstreamError;cyber policy、上下文超限、错误透传规则、ShouldHandleErrorCode 的不换号分支保持一致。
    • 守卫 openAIWSHandshakeHTTPPolicyApplies:OpenAI 平台、首轮、无 previous_response_id、状态码 ≥ 400、不绑定既有连接。
    • 同账号重试等待期间由单一读取者预读客户端帧(最多缓存一条,超出以 1013 结束),保证客户端断开能及时感知。
  3. WS 账号满槽对齐 HTTP 排队等待并覆盖透传续聊轮(对应上表 6)
    • 新增 acquireWSAccountSlot:先快抢;失败且调度器给了等待计划时,用 IncrementAccountWaitCount 入队(与 HTTP 同一个计数键,两条入口竞争同一额度,不建 WS 私有额度),再进入退避循环。
    • HTTP 的等待循环原样抽成 waitForConcurrencySlot 供两条入口共用;HTTP 行为不变,仅补一处:抢到槽的同时 ctx 已到期则释放该槽,不再带着过期 ctx 返回。
    • 同账号重试截止时间只限制当次排队,不污染整轮准入预算(否则重试窗口到期时连接会被 account is busy 关闭、不再换号)。
    • 新增日志 openai.websocket_account_wait_started / _finished(含 mode、phase、reason、wait_ms),上面的统计即来自这两条。
  4. WS 换号重放在历史不完整时拒绝跨账号迁移(评审修复)
    • 提交 1 的后续轮换号把“重放序列存在”当成了“掌握完整历史”。ctx_pool 连接的首帧若用 previous_response_id 续接连接建立之前的响应、只带增量输入,网关手里只有本连接内的片段,却仍会去掉 previous_response_id、把片段当完整历史发给替代账号,静默丢失连接前的上下文。
    • 新增 openAIWSAccountFailoverHistoryComplete:请求不带 previous_response_id 视为完整(客户端自带全量 input);带了则必须指向本连接上一轮响应、且此前序列完整。与重放序列在同一处、每个客户端轮次只计算一次。
    • 不完整时后续轮换号错误不携带重放载荷,handler 按既有逻辑关闭连接,由客户端重连恢复;新增日志 ingress_ws_account_failover_replay_skip reason=incomplete_history。
    • http_bridge 不受影响:桥接本来就一律去掉 previous_response_id、只发连接内已知的 input,换号载荷与原账号收到的内容相同。

非测试代码 +822 / −76(16 个文件),测试 +2487 / −10(18 个文件)。无 schema、无迁移、无前端改动、无新配置项;deploy/config.example.yaml 与 README 只补了行为说明。

测试

  • go build ./... 通过;三个提交各自 go build 与测试编译均通过。
  • go test -tags=unit ./internal/handler/... ./internal/server/... ./internal/config/... ./internal/testutil/... 全部通过。
  • go test -tags=unit ./internal/service/:本地(Windows)仅 TestOllamaProbeCallback_StaleLongDoesNotOverrideNewShort 失败,该用例与本 PR 无关(未改动相关文件,成因是 Windows 时钟粒度),其余全部通过。
  • golangci-lint v2.13.2(不带 build tag,与 CI 一致):handler / service / config / testutil / server 均 0 issues。
  • 新增用例要点:
    • 传输失败:17 类故障 × API Key / OAuth × 首轮 / 第二轮共 68 个叶子场景;显式桥接与三种大首包自动桥接共 4 种入口的两轮跨账号用例;16 个取消 / 抢占 / deadline 场景验证不写冷却。
    • 后续轮换号:LaterTurnDialFailureCarriesCurrentTurnPayload、LaterTurnPrevResponseRecoveryKeepsFailoverContext(修复前失败、修复后通过);LaterTurnDialFailureRefusesFailoverWithoutFullHistory(首帧续接连接外响应、第二轮拨号失败,断言不携带重放载荷;修复前生成了只含 first / first-ok / second 的载荷)与 TestOpenAIWSAccountFailoverHistoryComplete(6 个场景)。
    • 握手:ctx_pool 与 passthrough 两种模式下 401 / 403 / 5xx 换号、同账号重试成功与耗尽、零次重试、预算耗尽、客户端断开、提前发送下一帧、并发槽释放。
    • 预算与模型:TestOpenAIWSTurnFailoverBudget*、TestOpenAIWSFirstOutputFailoverBudget*、TestOpenAIWSCurrentTurnFailoverUsesCurrentModel。
    • 准入等待:三种 WS 模式的后续轮各覆盖超时、队列满、等待中断开、先发帧再断开四种退出,另有准入期限边界与“重试截止时间不耗尽换号预算”用例;断言等待计数归零、他人槽位不动、用户槽释放、请求未到上游;用 miniredis 执行真实的并发计数脚本。
  • 真实客户端:以上提交在 fork 构建上经 Codex CLI 走网关 WS 模式做过工具调用全链路验证(文件真实创建、命令 exit 0、最终回复与文件内容一致),访问日志均为 GET / 101。验收脚本一旦检测到 falling back to HTTP 即判失败,确认不是靠退回 HTTP 才通过。故障账号场景下实测:选中故障账号 → 5 秒后传输失败 → 15 毫秒内改选健康账号 → 同一条 WS 内完成 4 轮。

关联

代价与保留项

  • 后续轮换号只在网关掌握完整历史时进行。 首帧用 previous_response_id 续接连接建立之前的响应,或某轮续接的不是本连接上一轮响应时,历史只存在于上游原账号,ctx_pool 后续轮失败不换号、直接关闭连接,由客户端重连恢复。Codex 重连时首帧发的是全量 input,不受影响:自用环境 8 天 9,400 次 WS 连接首帧中带 previous_response_id 的为 0。
  • 透传模式的保守边界刻意保留。 无状态码的拨号失败在 passthrough 下不换号(做过,已撤回:网关不保存透传对话的完整历史,新连接的首包可能带着依赖原账号的 previous_response_id,“本次请求尚未发出”不能证明它可以跨账号重放);握手重试只限首轮且无 previous_response_id;透传首次准入若带 previous_response_id 不排队,立即 1013,由客户端重试承担。
  • http_bridge 仍有两个已知缺口未做:账号配置的同账号重试在桥接路径被跳过;后续轮的 5xx 状态码错误不换号(只有 429 换)。
  • ctx_pool 在连接池 acquire 截止时拿到的是裸 context.DeadlineExceeded,仍回 1013 busy,不走换号。
  • 瞬态传输错误不冷却是 HTTP 的原规则,本 PR 不改分类器。 代价是持续性的“瞬态”故障只能靠每次换号兜底:实测一次代理故障 80 分钟里账号未被摘除(WS 换号 600 次、HTTP 345 次),请求都兜住了,但每次都多付一次拨号超时。是否需要“连续传输失败熔断”留待单独讨论。
  • 续聊轮排队与 HTTP 共用 sticky_session_max_waiting = 3,争用极端时队列满仍回 1013(上文窗口为 52 / 667 = 7.8%)。本 PR 不改默认值。
  • 排队不是 FIFO(沿用 HTTP 的带抖动轮询,新请求可能先拿到槽);用户并发槽不排队,仍立即拒绝;账号槽可用不代表连接池有连接,upstream websocket is busy 保留为独立原因。
  • 排队不产生容量:容量不足时等待会变成超时(上文中间阶段同期,HTTP 侧 120 秒等待超时 28 次)。
  • 上表 3、4、5 三项来自 HTTP / WS 源码对照,没有线上事故证据;其中首输出超时功能默认关闭(openai_first_output_timeout_seconds: 0),测试环境未启用,仅有单测。
  • 持久性故障的 10 分钟冷却落库与到期恢复是上游既有逻辑,本 PR 只是让 WS 能走到它;WS 侧这条路径只有单测证据,未做真机故障注入。
  • 效果数据不是同负载 A/B,见“口径与混杂因素”。

xlplbo added a commit to xlplbo/sub2api that referenced this pull request Sep 23, 2026
- feat(openai): 传输失败换号并按错误分档封禁
- fix(openai): WS 后续轮次换号错误携带当前轮请求
- fix(openai): 恢复HTTP与WS传输失败冷却策略
- fix(openai): 对齐WS透传拨号失败换号策略
- fix(openai): 补齐WS桥接后续轮传输失败换号
- fix(openai): 防止WS桥接已结束会话触发冷却
- revert(openai): 撤销WS透传拨号失败换号
- fix(openai): 修复OpenAI WS跨轮换号预算
- fix(openai): 修复OpenAI WS换号模型同步
- fix(openai): 对齐OpenAI WS握手失败重试
- fix(openai): 对齐OpenAI WS首输出超时换号次数
- fix(openai): 对齐OpenAI WS账号并发准入等待
- fix(openai): 修复WS重试期限污染账号准入预算
- fix(openai): 对齐OpenAI WS透传续聊轮排队等待
- fix(openai): WS 换号重放在历史不完整时拒绝跨账号迁移
xlplbo added a commit to xlplbo/sub2api that referenced this pull request Sep 23, 2026
- feat(openai): 传输失败换号并按错误分档封禁
- fix(openai): WS 后续轮次换号错误携带当前轮请求
- fix(openai): 恢复HTTP与WS传输失败冷却策略
- fix(openai): 对齐WS透传拨号失败换号策略
- fix(openai): 补齐WS桥接后续轮传输失败换号
- fix(openai): 防止WS桥接已结束会话触发冷却
- revert(openai): 撤销WS透传拨号失败换号
- fix(openai): 修复OpenAI WS跨轮换号预算
- fix(openai): 修复OpenAI WS换号模型同步
- fix(openai): 对齐OpenAI WS握手失败重试
- fix(openai): 对齐OpenAI WS首输出超时换号次数
- fix(openai): 对齐OpenAI WS账号并发准入等待
- fix(openai): 修复WS重试期限污染账号准入预算
- fix(openai): 对齐OpenAI WS透传续聊轮排队等待
- fix(openai): WS 换号重放在历史不完整时拒绝跨账号迁移
xlplbo added a commit to xlplbo/sub2api that referenced this pull request Sep 23, 2026
- feat(openai): 传输失败换号并按错误分档封禁
- fix(openai): WS 后续轮次换号错误携带当前轮请求
- fix(openai): 恢复HTTP与WS传输失败冷却策略
- fix(openai): 对齐WS透传拨号失败换号策略
- fix(openai): 补齐WS桥接后续轮传输失败换号
- fix(openai): 防止WS桥接已结束会话触发冷却
- revert(openai): 撤销WS透传拨号失败换号
- fix(openai): 修复OpenAI WS跨轮换号预算
- fix(openai): 修复OpenAI WS换号模型同步
- fix(openai): 对齐OpenAI WS握手失败重试
- fix(openai): 对齐OpenAI WS首输出超时换号次数
- fix(openai): 对齐OpenAI WS账号并发准入等待
- fix(openai): 修复WS重试期限污染账号准入预算
- fix(openai): 对齐OpenAI WS透传续聊轮排队等待
- fix(openai): WS 换号重放在历史不完整时拒绝跨账号迁移
xlplbo added a commit to xlplbo/sub2api that referenced this pull request Sep 28, 2026
- feat(openai): 传输失败换号并按错误分档封禁
- fix(openai): WS 后续轮次换号错误携带当前轮请求
- fix(openai): 恢复HTTP与WS传输失败冷却策略
- fix(openai): 对齐WS透传拨号失败换号策略
- fix(openai): 补齐WS桥接后续轮传输失败换号
- fix(openai): 防止WS桥接已结束会话触发冷却
- revert(openai): 撤销WS透传拨号失败换号
- fix(openai): 修复OpenAI WS跨轮换号预算
- fix(openai): 修复OpenAI WS换号模型同步
- fix(openai): 对齐OpenAI WS握手失败重试
- fix(openai): 对齐OpenAI WS首输出超时换号次数
- fix(openai): 对齐OpenAI WS账号并发准入等待
- fix(openai): 修复WS重试期限污染账号准入预算
- fix(openai): 对齐OpenAI WS透传续聊轮排队等待
- fix(openai): WS 换号重放在历史不完整时拒绝跨账号迁移
@xlplbo xlplbo closed this Sep 28, 2026
@xlplbo
xlplbo deleted the fix/openai-ws-transport-failover-admission-wait branch September 28, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant