Repository navigation
Conversation
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 换号重放在历史不完整时拒绝跨账号迁移
This was referenced Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
目的
OpenAI 平台的 HTTP 入口在“上游失败”和“账号满槽”两类情况下已有成熟策略:传输失败换号并冷却、按状态码分类换号与同账号重试、首输出超时换号上限、按调度器给出的等待计划排队等槽。WebSocket 入口(Codex 默认使用)没有接上其中大部分——同样的故障,HTTP 请求能换号或排队后完成,WS 连接被直接关闭,Codex 重试 5 次后退回 HTTP。
本 PR 把 WS 入口补到与 HTTP 一致。不新增策略、不新增配置项、不改 HTTP 既有行为:错误分类、冷却时长、重试次数、等待超时与队列上限全部复用 HTTP 现有的函数与配置。
解决的问题
handleOpenAIUpstreamTransportError:换号;持久性故障首次即临时禁调度 10 分钟1013 busy,不换号、不冷却;http_bridge 只有首轮走该入口,后续轮直接写错误帧shouldFailoverOpenAIUpstreamResponse分类后换号或同账号重试1011关闭客户端,不换号previous_response_id时复用 HTTP 的错误分类、账号状态与重试规则maxOpenAIFirstOutputTimeoutSwitches = 1AccountWaitPlan,按sticky_session_wait_timeout(120s)/sticky_session_max_waiting(3)或 fallback(30s / 100)排队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 不能。
1013 account is busy, please retry laterfalling back to HTTP两条入口共用账号并发槽,但忙时准入策略不同:WS 连接被踢掉,客户端重连 5 次、退回 HTTP,最后还是在同一个账号上完成。被踢还有额外代价:重连后若落到别的账号,那一轮的未命中输入均值约 3.4 万 token(留在原账号约 1.3 万;样本 12,738 轮,其中换账号 396 轮混有共用会话标识的子智能体,仅作量级参考)。
效果
账号满槽时的去向(续聊轮排队上线后的 16 小时窗口,3,038 个完成轮次;容量被刻意压到 2 个可调度账号共 6 个槽以制造争用):
按本 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关闭所以提交 3 把两者放在一起,中间形态不单独提交。透传续聊轮的帧在准入前一直由网关持有、尚未写入上游,排队期间上游到客户端的读取由独立 goroutine 继续(pong 照常),因此等待是安全的。
口径与混杂因素(请一并阅读)
传输失败换号(场景 1)改后: 18 小时窗口内 58 个请求各换号 1 次,全部从故障账号 B 切出(A / C / D 接替 6 / 24 / 28 次),57 个换号后正常产生用量,客户端没有再收到
1013。B 的故障表现为 SOCKS 连接失败,属瞬态类:按 HTTP 既有分类只换号、不冷却,60 秒内 7 次失败也没有触发任何禁调度——与 HTTP 行为一致。改动
前三个提交按依赖顺序排列,可逐个评审(每个提交都能独立编译并通过自身测试);第四个是评审后的修复:
handleOpenAIWSDialTransportFailure:仅当“拨号阶段 + 未收到 HTTP 状态码 + 非客户端取消”同时成立时,转交handleOpenAIUpstreamTransportError,返回UpstreamFailoverError让 handler 换号。有状态码的拒绝不走这里。doOpenAIUpstream失败分支由“仅首轮”放宽为 OpenAI 账号的所有轮次;进入共用入口前先检查抢占标记与ctx.Err(),避免已结束的会话给账号写入冷却。handleOpenAIWSHandshakeFailure直接调用 HTTP 的shouldFailoverOpenAIUpstreamResponse/handleOpenAIAccountUpstreamError;cyber policy、上下文超限、错误透传规则、ShouldHandleErrorCode的不换号分支保持一致。openAIWSHandshakeHTTPPolicyApplies:OpenAI 平台、首轮、无previous_response_id、状态码 ≥ 400、不绑定既有连接。1013结束),保证客户端断开能及时感知。acquireWSAccountSlot:先快抢;失败且调度器给了等待计划时,用IncrementAccountWaitCount入队(与 HTTP 同一个计数键,两条入口竞争同一额度,不建 WS 私有额度),再进入退避循环。waitForConcurrencySlot供两条入口共用;HTTP 行为不变,仅补一处:抢到槽的同时 ctx 已到期则释放该槽,不再带着过期 ctx 返回。account is busy关闭、不再换号)。openai.websocket_account_wait_started/_finished(含 mode、phase、reason、wait_ms),上面的统计即来自这两条。previous_response_id续接连接建立之前的响应、只带增量输入,网关手里只有本连接内的片段,却仍会去掉previous_response_id、把片段当完整历史发给替代账号,静默丢失连接前的上下文。openAIWSAccountFailoverHistoryComplete:请求不带previous_response_id视为完整(客户端自带全量 input);带了则必须指向本连接上一轮响应、且此前序列完整。与重放序列在同一处、每个客户端轮次只计算一次。ingress_ws_account_failover_replay_skip reason=incomplete_history。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-lintv2.13.2(不带 build tag,与 CI 一致):handler / service / config / testutil / server 均 0 issues。LaterTurnDialFailureCarriesCurrentTurnPayload、LaterTurnPrevResponseRecoveryKeepsFailoverContext(修复前失败、修复后通过);LaterTurnDialFailureRefusesFailoverWithoutFullHistory(首帧续接连接外响应、第二轮拨号失败,断言不携带重放载荷;修复前生成了只含 first / first-ok / second 的载荷)与TestOpenAIWSAccountFailoverHistoryComplete(6 个场景)。TestOpenAIWSTurnFailoverBudget*、TestOpenAIWSFirstOutputFailoverBudget*、TestOpenAIWSCurrentTurnFailoverUsesCurrentModel。GET / 101。验收脚本一旦检测到falling back to HTTP即判失败,确认不是靠退回 HTTP 才通过。故障账号场景下实测:选中故障账号 → 5 秒后传输失败 → 15 毫秒内改选健康账号 → 同一条 WS 内完成 4 轮。关联
websocket closed by server before response.completed”这条链:前几个处理会话抢占、保活与连接池容量,本 PR 处理剩下的两类成因:上游失败不换号、账号槽满不等待。1013 busy与频繁重连部分。这两个 issue 混有多种成因,不写 Fixes。gateway_helper.go,合并先后可能需要一方 rebase。代价与保留项
previous_response_id续接连接建立之前的响应,或某轮续接的不是本连接上一轮响应时,历史只存在于上游原账号,ctx_pool 后续轮失败不换号、直接关闭连接,由客户端重连恢复。Codex 重连时首帧发的是全量 input,不受影响:自用环境 8 天 9,400 次 WS 连接首帧中带previous_response_id的为 0。previous_response_id,“本次请求尚未发出”不能证明它可以跨账号重放);握手重试只限首轮且无previous_response_id;透传首次准入若带previous_response_id不排队,立即1013,由客户端重试承担。context.DeadlineExceeded,仍回1013 busy,不走换号。sticky_session_max_waiting = 3,争用极端时队列满仍回1013(上文窗口为 52 / 667 = 7.8%)。本 PR 不改默认值。upstream websocket is busy保留为独立原因。openai_first_output_timeout_seconds: 0),测试环境未启用,仅有单测。