Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/fix-429-transient-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 修复 routed 模型路径 429 限流重试缺失

## 问题描述

在 Codex Desktop 中使用 `tencent/deepseek-v4-pro` 模型时,频繁报错:

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve the reported Markdown lint warnings.

Add a language identifier such as text to the fenced blocks at Lines 7, 19, and 120. Surround the indented log block at Lines 120-122 with the required blank lines. End the file with one trailing newline.

Also applies to: 19-22, 120-122

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 7-7: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/fix-429-transient-retry.md` at line 7, Update the fenced code blocks in
docs/fix-429-transient-retry.md at the referenced sections to include an
appropriate language identifier such as text, add the required blank lines
around the indented log block, and ensure the file ends with exactly one
trailing newline.

Source: Linters/SAST tools

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。
Comment on lines +31 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the retry documentation with the implementation.

Update the document in three places:

  • Lines 33-34 omit 429 from the fetchWithTransientRetry status list, although src/lib/upstream-retry.ts Line 39 now includes it.
  • Lines 84-97 call the second changed path a web-search continuation. The changed code is fetchTerminalGuardContinuation at src/server/responses/core.ts Lines 2492-2516.
  • Lines 103-107 describe three transient attempts. Each attempt delegates to fetchWithResetRetry, so clarify that this is the transient-response retry count, not necessarily the total number of network attempts.

Incorrect path names and status lists can send operators to the wrong code path during incident analysis.

Also applies to: 82-107

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/fix-429-transient-retry.md` around lines 31 - 36, Update
docs/fix-429-transient-retry.md in the retry table to include HTTP 429 in
fetchWithTransientRetry’s status list, rename the second changed path to
fetchTerminalGuardContinuation, and clarify that the three attempts are
transient-response retries delegated through fetchWithResetRetry rather than
necessarily the total network-attempt count.


虽然请求返回后有一段 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)
```
2 changes: 1 addition & 1 deletion src/lib/upstream-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep 429 out of the shared transient taxonomy

When a Responses stream emits response.failed with error.status: 429, responsesSseToAnthropicSse consults this predicate and now emits Anthropic overloaded_error instead of the existing rate_limit_error; the focused tests/claude-outbound.test.ts case for a streamed 429 fails with exactly that type change. Please keep this shared error-mapping predicate limited to gateway transients or introduce a separate pre-stream retry predicate for HTTP 429.

AGENTS.md reference: src/AGENTS.md:L19-L20

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Codex pool cooldowns before same-account retries

With Codex account-pool passthrough, shouldRetryCodexPoolAccountQuota only runs after this generic retry layer returns. If account A's first 429 carries Retry-After but the later same-account retries omit it or shorten it, recordCodexUpstreamOutcome sees only the final 429, so the depleted account is cooled using the wrong/default window before trying account B. Please skip generic 429 retry for usesCodexForwardPoolAuth requests, or preserve the first 429 metadata for the pool retry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor long Retry-After values for 429s

Adding 429 to this shared retry predicate sends rate-limit responses through retryBackoffDelayMs, which caps Retry-After at the transient retry max of 5 seconds. For an upstream 429 with Retry-After: 60, opencodex will retry the same request after only 5 seconds (twice), violating the upstream's backoff directive and making throttling worse. Please use a 429-specific backoff path that preserves the advertised delay, or return the 429 when the delay exceeds the bounded transient-retry budget.

Useful? React with 👍 / 👎.

|| status === 520 || status === 521 || status === 522;
Comment on lines 38 to 40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for the 429 retry contract.

Add a unit test for a replayable request that returns 429 and then 200. Assert that the retry occurs, the transient response body is canceled, Retry-After is honored, and an aborted signal stops further attempts. Add integration coverage for both changed call sites in src/server/responses/core.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/upstream-retry.ts` around lines 38 - 40, Add regression tests
covering isTransientUpstreamStatus and the retry flow for a replayable request
returning 429 then 200: verify another attempt occurs, the transient response
body is canceled, Retry-After is respected, and an aborted signal prevents
further attempts. Add integration tests exercising both call sites changed in
responses/core.ts, reusing existing test helpers and patterns.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect the recovery-kind contract and all consumers.
rg -n -C 8 \
  'transient-5xx|AttemptRecoveryKind|applyUpstreamRecoveryInit|noteAttemptSend' \
  src --glob '*.ts'

Repository: lidge-jun/opencodex

Length of output: 43741


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- upstream retry implementation ---'
sed -n '1,255p' src/lib/upstream-retry.ts

printf '%s\n' '--- recovery metadata recording ---'
sed -n '930,985p' src/server/request-log.ts

printf '%s\n' '--- recovery-kind consumers and tests ---'
rg -n -C 6 \
  'recoveryKinds|transient-5xx|key-429|anthropic-oauth-429|applyUpstreamRecoveryInit|isTransientUpstreamStatus|fetchWithTransientRetry' \
  src test tests 2>/dev/null || true

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- focused transient retry tests ---'
sed -n '1,260p' tests/upstream-transient-retry.test.ts

printf '%s\n' '--- all exact recovery callback assertions ---'
rg -n -C 4 \
  'recoveryKinds|recovery\)|recovery\s*=>|firstRecovery|transient-5xx|connection-reset' \
  tests --glob '*.test.ts' --glob '*.spec.ts' \
  | head -n 500

Repository: lidge-jun/opencodex

Length of output: 12349


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
import re

retry = Path("src/lib/upstream-retry.ts").read_text()
tests = Path("tests/upstream-transient-retry.test.ts").read_text()
request_log = Path("src/server/request-log.ts").read_text()

status_match = re.search(
    r"return status === 429 \|\| status === 500 .*?status === 522;",
    retry,
    re.S,
)
assert status_match and "status === 429" in status_match.group(0)

assert "for (const s of [200, 400, 401, 429, 499, 507, 529])" in tests
assert "expect(isTransientUpstreamStatus(s)).toBe(false)" in tests

assert 'export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";' in retry
assert 'res = await fetchWithResetRetry(doFetch, opts, "transient-5xx");' in retry
assert 'if (recovery !== "connection-reset")' in retry
assert 'attempt.recoveryKinds.push(recovery)' in request_log

print("implementation: 429 is classified as transient")
print("test: 429 is still expected to be non-transient")
print("callback: every status retry is tagged transient-5xx")
print("request init: only connection-reset changes headers/keepalive")
print("metadata: recovery kind is persisted in recoveryKinds")
PY

Repository: lidge-jun/opencodex

Length of output: 421


Use a recovery kind that matches the transient status.

429 now enters fetchWithTransientRetry, but line 236 records "transient-5xx" for every status retry. noteAttemptSend persists this value in recoveryKinds, making 429 metadata inaccurate. Add a 429-specific or neutral recovery kind, update its validators and consumers, and update tests/upstream-transient-retry.test.ts, which still expects 429 to be non-transient. Add a 429 retry assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/upstream-retry.ts` around lines 38 - 40, The retry flow currently
records every transient status as “transient-5xx”, mislabeling HTTP 429 recovery
metadata. Update fetchWithTransientRetry and noteAttemptSend to use a
429-specific or neutral recovery kind, then update all recovery-kind validators
and consumers accordingly; revise tests/upstream-transient-retry.test.ts to
treat 429 as transient and add an assertion that it retries.

}

Expand Down
4 changes: 2 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,7 +2269,7 @@ async function handleResponsesInner(
stream: parsed.stream,
});
} else {
upstreamResponse = await fetchWithResetRetry(
upstreamResponse = await fetchWithTransientRetry(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let key-pool 429s reach the rotation loop first

For routed providers with an apiKeyPool, wrapping the initial fetch in fetchWithTransientRetry consumes 429 responses before the existing key-failover loop can cool the failed key and rotate. In the existing server-key-failover-e2e scenario where key A returns 429 and the retry succeeds, the second request now still uses key A, so the pool never records the cooldown or advances to key B; persistent 429s also wait through same-key backoff before failover. Please bypass transient 429 retry when key-pool failover is available, or perform rotation inside the retry path.

Useful? React with 👍 / 👎.

recovery => {
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
Expand Down Expand Up @@ -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(
Expand Down
Loading