Skip to content

CodeRouter: hold capacity errors on the same model instead of failing fast - #15310

Merged
teamleaderleo merged 2 commits into
mainfrom
fix/coderouter-hold-retry
Sep 29, 2026
Merged

teamleaderleo merged 2 commits into
mainfrom
fix/coderouter-hold-retry

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

A capacity blip ended autonomous agent turns. When every Claude or Codex account was cooling, or four accounts had failed, CodeRouter answered 503 overloaded_error. Codex and Claude Code show that as a final error and stop until a human types "continue" (#15301).

CodeRouter now holds the request and retries the same model instead of failing fast:

  • When a routing round ends on a transient failure (429, 5xx/529, an overloaded or "model at capacity" SSE event before any output, a transport error), the proxy waits and replays the same body. Waits use jittered exponential backoff (0.5 s → 15–30 s) and never end before the soonest account cooldown, which already carries the upstream retry-after.
  • The hold lasts up to CODEROUTER_CAPACITY_HOLD_MS (default 20 minutes). It is capped by the existing 25-minute header budget, so the function still answers before its 30-minute maxDuration.
  • It never holds once response bytes reached the client, and it never substitutes a model.
  • It fails at once, as today, when no account recovers within the budget: every account has a revoked credential, or a Codex workspace quota resets after the budget. Claude's selector skips invalid_credential cooldowns when computing the recovery time; Codex gets a small nextCapacityAvailableAt query for the same purpose.
  • route_events gains held_ms and hold_count, and the PostHog trace carries coderouter_held_ms / coderouter_hold_count, so capacity pain shows up as latency.

Deploy order: ClickHouse migration web/db/clickhouse/006_route_events_capacity_hold.sql (additive ADD COLUMN IF NOT EXISTS ... DEFAULT 0) must be applied to coderouter_dev and coderouter before merge. Otherwise route-event inserts carry unknown columns.

Part 2 of #15301 (agents auto-resume after a retryable failure) is a separate PR.

Validation

  • Commit 1 adds the tests only; commit 2 adds the change.
  • bun test tests/coderouter-*.test.ts*: 506 pass, 0 fail (rebased on main). CI: commit 1 red (web typecheck and web tests), commit 2 green (59/59). New tests: a 529 → 429 → transport → 529 → 200 storm on one Claude account returns 200 with four holds and the same model on every replay; the same for Codex, with each wait honoring the recorded cooldown; the budget cap; no hold for a revoked credential or an hour-long quota; no replay after output started.
  • tsc --noEmit clean; lint:complexity clean.
  • Production build against a mocked upstream (local next build + next start; this machine cannot reach cmux's Vercel previews). A throwaway, unpushed branch swaps in stub auth, one stub account with in-memory cooldowns, and a mock upstream, then drives the real /v1/messages and /v1/responses routes. Every storm shorter than the budget reached the client as a 200 on the same model; the same build with CODEROUTER_CAPACITY_HOLD_MS=0 returns the 529 in 2.3 s, which is the old behavior.
Transcript
$ # Claude: 3 x 529, then 200 (hold on, default 20 min budget)
=== claude-529 (3 x 529) after 94s
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"held and served"}}
HTTP 200 in 93.024501s

$ # Claude: 2 x 429 retry-after 5, then 200 (same account, concurrent)
=== claude-429 (2 x 429) after 68s
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"held and served"}}
HTTP 200 in 67.095940s

$ # Codex: 2 x "Selected model is at capacity" SSE event, then 200
data: {"type":"response.output_text.delta","delta":"held and served"}
HTTP 200 in 121.558265s
elapsed 122s

# server log (mock upstream)
[mock-upstream +   0.0s] claude-529 client request received (failures=3, kind=529)
[mock-upstream +   1.7s] claude-529 upstream POST model=claude-sonnet-4-5
[mock-upstream +   1.7s] claude-529 attempt 1 -> 529
[mock-upstream +   1.7s] claude-529 cooldown verify-claude-1 20000 ms (upstream_unavailable)
[mock-upstream +   0.0s] claude-429 client request received (failures=2, kind=429)
[mock-upstream +  20.7s] claude-429 upstream POST model=claude-sonnet-4-5
[mock-upstream +  20.7s] claude-429 attempt 1 -> 429
[mock-upstream +  20.7s] claude-429 cooldown verify-claude-1 5000 ms (rate_limited)
[mock-upstream +  25.9s] claude-429 upstream POST model=claude-sonnet-4-5
[mock-upstream +  25.9s] claude-429 attempt 2 -> 429
[mock-upstream +  25.9s] claude-429 cooldown verify-claude-1 5000 ms (rate_limited)
[mock-upstream +  36.1s] claude-529 upstream POST model=claude-sonnet-4-5
[mock-upstream +  36.1s] claude-529 attempt 2 -> 529
[mock-upstream +  36.1s] claude-529 cooldown verify-claude-1 20000 ms (upstream_unavailable)
[mock-upstream +  61.6s] claude-429 upstream POST model=claude-sonnet-4-5
[mock-upstream +  61.6s] claude-429 attempt 3 -> 200 stream
[mock-upstream +  61.8s] claude-429 client response status=200
[mock-upstream +  65.5s] claude-529 upstream POST model=claude-sonnet-4-5
[mock-upstream +  65.5s] claude-529 attempt 3 -> 529
[mock-upstream +  65.5s] claude-529 cooldown verify-claude-1 20000 ms (upstream_unavailable)
[mock-upstream +  89.0s] claude-529 upstream POST model=claude-sonnet-4-5
[mock-upstream +  89.0s] claude-529 attempt 4 -> 200 stream
[mock-upstream +  89.0s] claude-529 client response status=200
[mock-upstream +   0.0s] codex-capacity client request received (failures=2, kind=529)
[mock-upstream +   0.0s] codex-capacity upstream POST model=gpt-5.6-sol
[mock-upstream +   0.0s] codex-capacity attempt 1 -> capacity SSE event
[mock-upstream +   0.2s] codex-capacity cooldown verify-codex-1 60000 ms (model_capacity)
[mock-upstream +  60.5s] codex-capacity upstream POST model=gpt-5.6-sol
[mock-upstream +  60.5s] codex-capacity attempt 2 -> capacity SSE event
[mock-upstream +  60.5s] codex-capacity cooldown verify-codex-1 60000 ms (model_capacity)
[mock-upstream + 121.1s] codex-capacity upstream POST model=gpt-5.6-sol
[mock-upstream + 121.1s] codex-capacity attempt 3 -> 200 stream
[mock-upstream + 121.1s] codex-capacity client response status=200

$ # Same build, CODEROUTER_CAPACITY_HOLD_MS=0 (old fail-fast), warm server, 1 x 529
run 2 HTTP 529 in 2.254531s
[mock-upstream +   0.0s] nohold-2 client request received (failures=1, kind=529)
[mock-upstream +   0.0s] nohold-2 upstream POST model=claude-sonnet-4-5
[mock-upstream +   0.0s] nohold-2 attempt 1 -> 529
[mock-upstream +   0.5s] nohold-2 cooldown verify-claude-1 20000 ms (upstream_unavailable)
[mock-upstream +   0.7s] nohold-2 client response status=529

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: dce72fc7-5d70-46e2-ad45-bb4aa6bcf695

📥 Commits

Reviewing files that changed from the base of the PR and between 1755ea8 and 874d45e.

📒 Files selected for processing (17)
  • docs/coderouter-operations.md
  • web/.env.example
  • web/db/clickhouse/006_route_events_capacity_hold.sql
  • web/services/coderouter/README.md
  • web/services/coderouter/capacityHold.ts
  • web/services/coderouter/claudeProxy.ts
  • web/services/coderouter/claudeUpstream.ts
  • web/services/coderouter/codexProxy.ts
  • web/services/coderouter/faultClassification.ts
  • web/services/coderouter/repository.ts
  • web/services/coderouter/requestTelemetry.ts
  • web/services/coderouter/usageLedger.ts
  • web/tests/coderouter-capacity-hold.test.ts
  • web/tests/coderouter-claude-proxy.test.ts
  • web/tests/coderouter-claude-upstream.test.ts
  • web/tests/coderouter-responses-proxy.test.ts
  • web/tests/coderouter-usage-ledger.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

teamleaderleo and others added 2 commits September 28, 2026 06:45
Adds tests for issue #15301: a capacity storm (529, 429, transport,
overloaded SSE events) shorter than the hold budget must reach the client
as a success on the same model, with the wait recorded in telemetry. These
fail until the proxies hold instead of failing fast.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iling fast

A capacity blip ended autonomous agent turns: after four accounts, or when
every account was cooling, the proxies answered 503 and the agent CLI
stopped. The Claude and Codex proxies now hold the request when a routing
round ends on a transient failure (capacity, 429, 5xx/529, transport),
wait with jittered exponential backoff that honors the soonest account
cooldown, and replay the same body and model. The hold lasts up to
CODEROUTER_CAPACITY_HOLD_MS (20 minutes), bounded by the header budget,
never starts after output reached the client, and ends at once when no
account recovers in time (revoked credential, later quota reset).

route_events gains held_ms and hold_count (ClickHouse migration 006), and
the request outcome carries them to PostHog traces.

Refs #15301

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@teamleaderleo
teamleaderleo force-pushed the fix/coderouter-hold-retry branch from f5d72f5 to b95d040 Compare September 28, 2026 10:46
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

CI failure attribution

CI passes on 874d45eaec (run 36413333748 attempt 1).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's.

@teamleaderleo
teamleaderleo marked this pull request as ready for review September 29, 2026 15:29
@teamleaderleo
teamleaderleo enabled auto-merge (squash) September 29, 2026 15:29
@teamleaderleo
teamleaderleo merged commit 9ed9294 into main Sep 29, 2026
73 checks passed
@teamleaderleo
teamleaderleo deleted the fix/coderouter-hold-retry branch September 29, 2026 15:42
@github-actions

Copy link
Copy Markdown
Contributor

Merge receipt for 874d45eaec: every check was green at merge (19 verified; 17 skipped by policy). Full suite runs on main after merge.

rustybret pushed a commit to rustybret/bmux that referenced this pull request Sep 29, 2026
4e0f7d2 fix(bash): keep $? for PROMPT_COMMAND hooks after cmux's (manaflow-ai#15255)
ae49bf5 fix(examples): show custom description in Project Worktrees sidebar (manaflow-ai#15256)
a9a229d Add cross-provider token usage accounting for agent transcripts (manaflow-ai#15332)
860619f Add a .worktreeinclude reader for seeding new worktrees (manaflow-ai#15413)
3edbd83 Clear the stale Needs input badge when Claude's permission is decided in the terminal (manaflow-ai#15170)
9ed9294 CodeRouter: hold capacity errors on the same model instead of failing fast (manaflow-ai#15310)
56d4547 docs: add a front door for outside contributors (manaflow-ai#15263)
799f906 fix(ci): recognize GUI token acquisition failures (manaflow-ai#15449)
f118d43 ci: age parked builds by measured reuse distance (manaflow-ai#15616)
1f6744d ci: harden overflow switch recovery (manaflow-ai#15617)
9987778 Predicted echo: remote terminals only, withdraw on pasted and sent input (manaflow-ai#15211)
d9e199b Subtle selection follow-ups: group header hairline, no focus re-render for legacy rows, cmux.json test (manaflow-ai#15195)
c13afe1 test: cover UTF-8 workspace create commands (manaflow-ai#15622)
e76a660 fix: preserve Claude remote-control names on restore (manaflow-ai#15619)
900f248 feat: expose cmux-owned scratch metadata in session listing (manaflow-ai#15615)
b5604fa ci: say why compiled-product reuse refused an artifact (manaflow-ai#15553)

# Conflicts:
#	.github/workflows/ci-cloud-overflow-probe.yml
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