From 9941cfd653e4112c826acef19a9dd202fa8d190e Mon Sep 17 00:00:00 2001 From: blairevan Date: Tue, 4 Aug 2026 11:51:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B0=86=20routed=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9A=84=20fetchWithResetRetry=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20fetchWithTransientRetry=EF=BC=8C=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=20transient=20=E7=8A=B6=E6=80=81=E7=A0=81=E4=B8=AD=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20429?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Codex Desktop 使用 DeepSeek 等非 OpenAI 模型时频繁触发 429 限流的问题。 原逻辑只对 passthrough 路径(ChatGPT 后端)使用带重试的 fetchWithTransientRetry, 而 routed 模型路径仅使用 fetchWithResetRetry(仅重试 TCP 连接错误), 导致 429 直接传递给客户端,Codex 多次重试后报 exceed retry limit。 改动: - upstream-retry.ts: isTransientUpstreamStatus 增加 429 状态码 - core.ts: routed 路径和 continuation 路径改用 fetchWithTransientRetry --- src/lib/upstream-retry.ts | 2 +- src/server/responses/core.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index f767f045e..e001adad5 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -36,7 +36,7 @@ const TRANSIENT_RETRY_SLOW_ATTEMPT_MS = 15_000; * but is deliberately excluded (storage-class, not gateway-transient). */ export function isTransientUpstreamStatus(status: number): boolean { - return status === 500 || status === 502 || status === 503 || status === 504 + return status === 429 || status === 500 || status === 502 || status === 503 || status === 504 || status === 520 || status === 521 || status === 522; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e714b3d81..896dd402e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2269,7 +2269,7 @@ async function handleResponsesInner( stream: parsed.stream, }); } else { - upstreamResponse = await fetchWithResetRetry( + upstreamResponse = await fetchWithTransientRetry( recovery => { noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery); return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({ @@ -2513,7 +2513,7 @@ async function handleResponsesInner( stream: nextParsed.stream, }); } else { - response = await fetchWithResetRetry( + response = await fetchWithTransientRetry( recovery => { noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery); return fetchWithHeaderTimeout( From 427470752f36b4dc36b4289da20f03ca9f764392 Mon Sep 17 00:00:00 2001 From: blairevan Date: Tue, 4 Aug 2026 12:32:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20429=20=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E4=BF=AE=E5=A4=8D=E7=9A=84=E8=AE=BE=E8=AE=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/fix-429-transient-retry.md | 122 ++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/fix-429-transient-retry.md diff --git a/docs/fix-429-transient-retry.md b/docs/fix-429-transient-retry.md new file mode 100644 index 000000000..9fdf9907d --- /dev/null +++ b/docs/fix-429-transient-retry.md @@ -0,0 +1,122 @@ +# 修复 routed 模型路径 429 限流重试缺失 + +## 问题描述 + +在 Codex Desktop 中使用 `tencent/deepseek-v4-pro` 模型时,频繁报错: + +``` +exceeded retry limit, last status: 429 Too Many Requests +``` + +但同一个 API Key 在 Claude CLI 中使用完全正常,不会触发 429 报错。 + +## 根因分析 + +### 架构差异 + +Codex Desktop 和 Claude CLI 使用了**两个不同的本地代理**: + +``` +Codex Desktop → opencodex (127.0.0.1:10100) → DeepSeek API ← 429! +Claude CLI → cc-switch (127.0.0.1:15721) → DeepSeek API ← 正常 +``` + +- **Claude CLI** 使用 `cc-switch`(`/Applications/CC Switch.app`),对 429 有成熟的反压和退避机制 +- **Codex Desktop** 使用 `opencodex`(`@bitkyc08/opencodex`),对 429 的处理存在漏洞 + +### opencodex 的 429 处理缺陷 + +opencodex 中,请求路径分为两类: + +| 路径 | 使用的重试函数 | 重试范围 | +|------|---------------|---------| +| **passthrough**(ChatGPT 后端) | `fetchWithTransientRetry` | TCP 连接错误 + 500/502/503/504/520/521/522 | +| **routed**(DeepSeek 等非 OpenAI 模型) | `fetchWithResetRetry` | **仅 TCP 连接错误**(ECONNRESET/EPIPE) | + +`fetchWithResetRetry` 只重试 TCP 层面的连接断开,**不重试 HTTP 层面的错误状态码**。当上游返回 429 时,它直接透传给 Codex Desktop。 + +虽然请求返回后有一段 recovery loop 尝试处理 429: + +```typescript +// core.ts 原有逻辑 +while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) { + const rotated = rotateProviderTransportOn429(config, route.providerName, { ... }); + if (!rotated) break; // 单 Key 配置直接退出 + // ... +} +``` + +但 `hasKeyPoolFailover` 依赖多 Key 池配置,**单 Key 场景下直接返回 false**,429 原样返回给 Codex Desktop。Codex Desktop 收到 429 后自行重试,多次失败后报 `exceeded retry limit`。 + +### 为什么 passthrough 路径没问题 + +passthrough 路径(ChatGPT 后端)使用的是 `fetchWithTransientRetry`,在收到 transient 状态码时会自动退避重试: + +```typescript +// core.ts passthrough 路径(已有正确实现) +upstreamResponse = await fetchWithTransientRetry( + recovery => { + noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery); + return fetchWithHeaderTimeout(request.url, ...); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, +); +``` + +## 修复方案 + +将 routed 模型路径的请求也纳入 `fetchWithTransientRetry` 保护,同时把 429 加入 transient 状态码列表。 + +### 修改 1:`src/lib/upstream-retry.ts` + +在 `isTransientUpstreamStatus` 中增加 429: + +```diff + export function isTransientUpstreamStatus(status: number): boolean { +- return status === 500 || status === 502 || status === 503 || status === 504 ++ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504 + || status === 520 || status === 521 || status === 522; + } +``` + +### 修改 2:`src/server/responses/core.ts` + +将 routed 路径(主请求 + continuation/web-search 请求)的 `fetchWithResetRetry` 替换为 `fetchWithTransientRetry`: + +```diff +- upstreamResponse = await fetchWithResetRetry( ++ upstreamResponse = await fetchWithTransientRetry( + recovery => { + noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery); + return fetchWithHeaderTimeout(request.url, ...); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); +``` + +共两处调用点(主请求 + web-search continuation 请求)。 + +### 重试参数 + +| 参数 | 值 | 说明 | +|------|-----|------| +| 最大重试次数 | 3(1 次初始 + 2 次重试) | `TRANSIENT_RETRY_MAX_ATTEMPTS` | +| 基础退避延迟 | 400ms | `TRANSIENT_RETRY_BASE_DELAY_MS` | +| 最大退避延迟 | 5,000ms | `TRANSIENT_RETRY_MAX_DELAY_MS` | +| 慢请求预算 | 15,000ms | `TRANSIENT_RETRY_SLOW_ATTEMPT_MS` | +| Retry-After | 自动读取响应头 | 尊重上游返回的等待时间 | + +## 影响范围 + +- **passthrough 路径**(ChatGPT 后端):增加 429 重试,行为更健壮 +- **routed 路径**(DeepSeek / 所有非 OpenAI 模型):从"不重试 HTTP 错误"变为"重试 transient 错误(含 429)" +- **其他 429 处理**:`hasKeyPoolFailover` 的多 Key 轮转逻辑保持不变,作为 `fetchWithTransientRetry` 耗尽后的第二层防护 + +## 验证方法 + +1. 在 Codex Desktop 中使用 `deepseek-v4-pro` 进行正常对话 +2. 观察是否还会出现 `exceeded retry limit, last status: 429` 错误 +3. 在 opencodex 日志中,如果发生 429 重试,会看到类似日志: + ``` + [upstream-retry] transient 429 (api.deepseek.com) — retrying (2/3) + ``` \ No newline at end of file