-
Notifications
You must be signed in to change notification settings - Fork 523
feat(proxy): opt-in same-target 429 wait-and-retry before key failover (#487) #865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
harryzhou2000
wants to merge
18
commits into
lidge-jun:dev
Choose a base branch
from
harryzhou2000:feat/429-same-target-retry
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
667ad08
feat(proxy): opt-in same-target 429 wait-and-retry before key failove…
harryzhou2000 2efb887
fix(proxy): address audit round-1 findings for retryOn429
harryzhou2000 a06a416
fix(proxy): extend retryOn429 to all key-auth surfaces (audit round-3…
harryzhou2000 58c36c9
fix(proxy): resolve review-bot round on retryOn429 (fail-closed auth,…
harryzhou2000 0feede1
docs: add JSDoc for retryOn429 helpers (CodeRabbit docstring coverage)
harryzhou2000 568e565
fix(proxy): second review-bot pass (awaited body cancel, stale-deadli…
harryzhou2000 3c19337
docs: lift docstring coverage on the retryOn429 diff
harryzhou2000 1af83c3
docs: attach JSDoc directly to remaining diff-touched declarations
harryzhou2000 d4e1978
fix(proxy): address review round 3 (per-request bridge budget, single…
harryzhou2000 58bf694
fix(proxy): local audit round (shared request budget, awaited body ca…
harryzhou2000 4fee87b
fix(config): redact unrecognized retryOn429 field names in load warnings
harryzhou2000 5945711
refactor(xai): rename pinned request-id param; tighten secret-warning…
harryzhou2000 e65106f
fix(config): JSON-escape redacted retryOn429 field names in load warn…
harryzhou2000 89535fb
fix(config): redact and JSON-escape provider names in retryOn429 load…
harryzhou2000 eb9890e
fix(proxy): maintainer review round (watchdog-fed backoffs, one cache…
harryzhou2000 4e17205
fix(proxy): clamp heartbeat interval guard; pin emptied-policy opt-in…
harryzhou2000 e8613e3
Merge remote-tracking branch 'upstream/dev' into feat/429-same-target…
harryzhou2000 0260181
fix(proxy): normalize NaN heartbeat intervals to the 1ms step
harryzhou2000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # 000 — same-target 429 retry: why the client cannot do it | ||
|
|
||
| ## 요약 | ||
|
|
||
| Codex turns die instantly when an upstream provider (e.g. BLSC with a single API key) | ||
| returns HTTP 429. The proxy forwards the error with `Retry-After` (#514), but Codex | ||
| never acts on it. | ||
|
|
||
| ## Evidence | ||
|
|
||
| - **Upstream [openai/codex#30471](https://github.com/openai/codex/issues/30471) (open):** | ||
| `codex-rs/codex-api/src/provider.rs` has a retry policy with an explicit `retry_429` flag, | ||
| and the endpoint configs set it to `false`; HTTP 429 is only mapped to `UsageLimitReached` | ||
| when the body matches the Codex usage-limit shape, otherwise it falls through to a generic | ||
| transport/API error and surfaces as the misleading "exceeded retry limit" message. The | ||
| suggested fix is to reword the error, deliberately preserving `retry_429=false` — there is | ||
| no user-facing knob to enable client-side 429 retry. | ||
| - **opencodex issue #487 (auto-closed as `not_planned` for missing detail):** the author | ||
| requested exactly this feature and noted "The Codex client itself does not retry 429 | ||
| (upstream: openai/codex#30471) — it only retries 5xx — so a proxy-side retry would fill a | ||
| real gap." | ||
| - **opencodex PR #514 (merged):** attaches a client `Retry-After` (upstream header → message | ||
| hint → default 2) across Responses, chat completions, Claude messages, and passthrough | ||
| paths. Its own test plan lists the Codex recovery check as *unverified*. Claude Code honors | ||
| `Retry-After` and absorbs 429s (#507); Codex does not. | ||
|
|
||
| ## Current proxy behavior (v2.8.0 / dev) | ||
|
|
||
| - `src/server/responses/core.ts` recovery loop: the only 429 retry is multi-key failover | ||
| (`hasKeyPoolFailover` requires ≥2 keys in `apiKeyPool`). Single-key pools no-op, and the | ||
| 429 falls through to `rate_limit_error` with `Retry-After`. | ||
| - `src/server/chat-completions.ts` and the routed Claude path reuse `handleResponses`, so one | ||
| insertion point covers all three inbound surfaces. | ||
|
|
||
| ## Conclusion | ||
|
|
||
| Client-side 429 retry does not exist and is not planned. The fix must live in the proxy: | ||
| an opt-in wait-and-retry that replays the identical pre-stream request on the same key before | ||
| any failover. |
114 changes: 114 additions & 0 deletions
114
devlog/_plan/260802_429_same_target_retry/010_design.md
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # 010 — design: `retryOn429` same-target wait-and-retry | ||
|
|
||
| 의존: `000_research.md` | ||
|
|
||
| ## Goal | ||
|
|
||
| Provider-level opt-in knob: on HTTP 429, wait (upstream `Retry-After` or a fixed interval) | ||
| and replay the identical request on the same key, up to `attempts` extra times, before the | ||
| existing multi-key failover runs. Default off → zero behavior change for existing setups. | ||
|
|
||
| ## Surface | ||
|
|
||
| ```jsonc | ||
| // ~/.opencodex/config.json → providers.<name> | ||
| "retryOn429": { | ||
| "enabled": true, // object presence also enables; false disables | ||
| "attempts": 3, // extra replays after the first 429 (1..20) | ||
| "intervalMs": 5000, // fixed wait when no usable Retry-After | ||
| "maxIntervalMs": 60000, // cap for any single wait | ||
| "respectRetryAfter": true // prefer the upstream Retry-After when parseable | ||
| } | ||
| ``` | ||
|
|
||
| ## Implementation | ||
|
|
||
| - `src/types.ts`: `RateLimitRetryPolicy` interface + `OcxProviderConfig.retryOn429`. | ||
| - `src/config.ts`: zod schema entry (zod's default strip inside the object — an unknown key is | ||
| dropped, never a config-rejecting error; the outer provider schema stays passthrough). | ||
| Load-time degradation: one hand-edited invalid optional field (e.g. `attempts: 0`) is dropped | ||
| with a warning instead of tripping the whole schema and hiding all providers behind a default | ||
| config; misnamed keys (e.g. `attempt`) are warned about too; the management write boundary | ||
| still rejects invalid policies. | ||
| - `src/providers/key-failover.ts`: `rateLimitRetryPolicyFor` (normalize/default) and | ||
| `rateLimitRetryDelayMs` (Retry-After seconds/HTTP-date → capped, else `intervalMs`), | ||
| reusing the existing `parseRetryAfterMs` cooldown parser; the fixed fallback is capped at | ||
| `maxIntervalMs` too, so a single wait never exceeds the cap. Fail closed: only `authMode: | ||
| "key"` (or the documented omitted default for custom API-key providers) may use replays — | ||
| OAuth/forward are never replayed on the same token, local runtimes have no remote key to | ||
| preserve, and unknown values are rejected. `providerConfigSeed` preserves the registry auth | ||
| kind (including `"local"`) so the gate survives the seed round-trip. | ||
| - `src/usage/log.ts`: new `AttemptRecoveryKind` member `"rate-limit-429"`. | ||
| - `src/server/responses/core.ts`: in the pre-stream recovery loop, BEFORE the multi-key | ||
| failover `while`, wait then `rebuildAndRefetch("rate-limit-429")`. Abort during the wait | ||
| cancels the client request (the unread 429 body is released first). The retry budget lives | ||
| OUTSIDE the recovery loop, so a 413/401 replay that comes back 429 cannot re-arm a fresh | ||
| budget — bounded to `attempts` per request. After attempts are exhausted the existing | ||
| failover and error mapping run unchanged. The same wait-and-replay applies to the other | ||
| key-auth surfaces that bypass that loop: | ||
| - Responses passthrough wire (`openai-responses` key-auth gateways, e.g. the built-in | ||
| DeepSeek preset) — pre-relay, before the forward-pool logic; | ||
| - image/video bridge and web-search sidecar loops (`src/images/loop.ts`, | ||
| `src/web-search/loop.ts`) — before their `on429` key rotation; | ||
| - Anthropic terminal-guard continuations — before key/account failover. | ||
| Every surface releases (and awaits the cancellation of) the unread 429 body BEFORE the | ||
| backoff, records the `rate-limit-429` recovery kind on replay sends, and (bridges) clears the | ||
| old response-header deadline before the wait and starts a fresh one afterward, re-checking | ||
| client cancellation before telemetry and replay. | ||
| Covers Responses, chat completions, and routed Claude messages (they all enter | ||
| `handleResponses`). | ||
|
|
||
| ## Safety | ||
|
|
||
| - Pre-stream only: a 429 arrives before any bytes are relayed, so replaying the string-body | ||
| request is lossless (same invariant as the transient-5xx layer in `lib/upstream-retry.ts`). | ||
| - Ordering: same-key retries run before failover, so "primary-first" users keep their key on | ||
| rate-limit blips; failover still works after retries exhaust. | ||
| - Retry-wait bound: the SLEEP component is at most `attempts × maxIntervalMs` (default | ||
| 3 × 60 s = 180 s) when honoring upstream `Retry-After`; `attempts × intervalMs` | ||
| (default 15 s) when `respectRetryAfter=false` or no header is present. Total request | ||
| latency is higher: every attempt also consumes its own connect/response time (bounded by | ||
| `connectTimeoutMs`), so the documented bound covers deliberate waits only. | ||
| - Identical replay: rebuilds are deterministic for the same parsed request (same serialized | ||
| body and auth headers); the passthrough/continuation/e2e tests assert byte-identical bodies | ||
| and identical auth headers across replays, not just send counts. | ||
| - Abort during the wait: the sleep is abort-aware — when the server observes the client | ||
| disconnect (Bun propagates this asynchronously, observed 1–10 s), the wait is interrupted, | ||
| the unread 429 body is released, and the request is cancelled with 499 before any replay. | ||
| Because the propagation is async, a replay can still precede the cancel if the interval | ||
| elapses first; that is bounded by the same `attempts` budget. Terminal continuations sleep | ||
| on the upstream signal, so a body-cancel (SSE already streaming) aborts the wait too. | ||
| - Concurrency: each request honors its own policy independently — no process-wide cooldown is | ||
| shared between concurrent requests (unlike the Kiro 429 pattern). Upstream volume per | ||
| request: same-key replays add at most `attempts` sends, then multi-key failover adds up to | ||
| `poolKeys − 1` more (or Anthropic account rotations), so the combined bound is | ||
| `attempts + poolKeys` sends — a storm multiplies by that factor per request, not by | ||
| `attempts + 1`. | ||
| - Header deadlines: the image/video and web-search bridge loops restart their response-header | ||
| deadline after each deliberate wait, so backoffs never consume the connect budget and a | ||
| rate-limit wait is never misattributed as a 504 header timeout. The old deadline is cleared | ||
| BEFORE the sleep and client cancellation is re-checked after it, so 499 always wins over a | ||
| stale-deadline edge. | ||
| - Expired `Retry-After`: a valid HTTP-date already in the past retries immediately (same as | ||
| numeric `Retry-After: 0`) instead of falling back to the fixed interval. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| - Recovery observability: every retry surface records the `rate-limit-429` recovery kind | ||
| (normal loop, passthrough wire, image/video bridge, web-search sidecar, terminal | ||
| continuations), so usage logs explain the extra sends. | ||
| - Final 429 still carries `Retry-After` for clients that honor it (Claude Code). | ||
|
|
||
| ## Tests | ||
|
|
||
| - `tests/rate-limit-retry.test.ts` — policy normalization (incl. OAuth/forward gating) + | ||
| delay computation (seconds, HTTP-date, `0`, malformed, cap; deterministic) + abort during | ||
| the wait: a directly-invoked `handleResponses` with a controlled abort signal returns 499, | ||
| cancels the unread 429 body, and performs no further upstream sends (deterministic — no | ||
| real-socket disconnect timing involved). | ||
| - `tests/usage-log.test.ts` — the `rate-limit-429` recovery kind survives persisted usage logs. | ||
| - `tests/server-rate-limit-retry-e2e.test.ts` — single-key replay to success, immediate | ||
| passthrough without the knob, exhausted attempts surface 429, and retry-before-failover | ||
| ordering with a 2-key pool, plus key-auth `openai-responses` passthrough replaying 429 on | ||
| the same key. | ||
| - `tests/terminal-guard-server.test.ts` — an Anthropic terminal-guard continuation that 429s | ||
| is replayed on the same key before the error surfaces (3 upstream sends). | ||
| - `tests/images/loop.test.ts` + `tests/web-search.test.ts` — the bridge loops replay 429 on | ||
| the same key before `on429` rotation runs (same-key sends counted, rotations zero). | ||
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
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
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
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.