Skip to content

fix: auto-switch Claude accounts after usage exhaustion#132

Open
cosmosjeon wants to merge 3 commits into
griffinmartin:mainfrom
cosmosjeon:fix/account-failover-on-usage-exhaustion
Open

fix: auto-switch Claude accounts after usage exhaustion#132
cosmosjeon wants to merge 3 commits into
griffinmartin:mainfrom
cosmosjeon:fix/account-failover-on-usage-exhaustion

Conversation

@cosmosjeon

@cosmosjeon cosmosjeon commented Apr 4, 2026

Copy link
Copy Markdown

Summary

  • fix the multi-account workflow where OpenCode keeps using an exhausted Claude account even after Claude Code has been re-authenticated with another account
  • detect conservative usage-exhaustion 429 responses, refresh the discovered local Claude accounts, switch to the next available account, and retry the request once
  • keep fallback auth state in sync during failover, cover replacement-account 401 recovery and sync-failure tolerance, and document the new behavior

Problem

When the active Claude account runs out of usage, re-authenticating Claude Code with another account does not help an already-running OpenCode session. The plugin keeps using the previously selected account, so the user still gets stuck on the exhausted account until they explicitly switch accounts or restart.

Root cause

  • the active account source is persisted and reused across requests
  • normal request flow does not refresh the discovered Claude account list
  • retry behavior stayed on the same account instead of attempting another locally authenticated account

Scope

This change only affects environments where more than one Claude account is available locally. In practice that mainly means macOS multi-account Keychain setups. Single-account users keep the existing behavior because there is no alternative account to fail over to.

Solution

  • treat only conservative usage-exhaustion 429 responses as failover candidates
  • reject generic rate-limit responses by requiring no retry-after header and excluding long-context errors
  • refresh the locally available Claude accounts and select a different source when one exists
  • persist the replacement account and retry the request once
  • keep auth.json sync best-effort so fallback-state writes do not block the request path
  • recover once more if the replacement account initially returns 401 and a refreshed token is available

Safety / non-goals

  • no failover on generic 429s, 401s, or 529s
  • no multi-hop account cycling across every available account
  • no polling-based account discovery on every request
  • if the replacement account also fails, return that response instead of continuing to rotate across accounts

Relation to other account-handling work

This is separate from #99. That PR focuses on stale credential refresh behavior and suffixed keychain refresh handling. This PR focuses on a different path: when the active account is usage-exhausted but another already-authenticated local Claude account is available, the request should fail over to that account.

Covered cases

  • switch to another account after a confirmed usage-exhaustion response
  • do not switch on a generic 429 rate limit
  • retry once more if the replacement account initially returns 401 and a refreshed token is available
  • keep the replacement account persisted even if the retried request still fails
  • continue the failover path even if auth.json sync throws

Testing

  • pnpm run build
  • pnpm test
  • node --test --test-name-pattern="auth fetch — account failover" --experimental-strip-types src/index.test.ts

cosmosjeon and others added 3 commits April 4, 2026 18:14
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@jmvbambico

jmvbambico commented Apr 10, 2026

Copy link
Copy Markdown

Possibley related: can we add support for detecting oauth json files made from this npm library?

https://github.com/manikandan-22/claude-code-accounts

@griffinmartin

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds automatic account failover when the active Claude account hits a usage-exhaustion 429: it detects the exhaustion conservatively (no retry-after header, body matches a set of usage-limit patterns, not a long-context error), refreshes the local account list, switches to the first available alternative, and retries once. If the replacement account returns 401, it reads fresh keychain credentials and retries a second time.

  • isUsageExhaustionResponse and findAlternativeAccount are new helpers in credentials.ts; the detection logic is conservative and well-tested across five unit-test cases.
  • reloadActiveCredentials was introduced for the failover 401 sub-retry, but was also silently substituted for getCachedCredentials() in the existing top-level 401 handler, removing the OAuth refresh that previously ran when a token expired mid-session.
  • syncAuthJson is called in the 401 handler without a try-catch, while every other syncAuthJson callsite added by this PR is wrapped in try { } catch { } to keep it best-effort.

Confidence Score: 3/5

The new failover path is well-designed and thoroughly tested, but two regressions in the modified 401 handler affect all users on the hot request path.

The 401 handler quietly lost its OAuth refresh capability (getCachedCredentials replaced by reloadActiveCredentials), and a bare syncAuthJson call can now throw out of the fetch instead of being swallowed as best-effort. Both issues predate the failover scenario and affect single-account users.

Files Needing Attention: src/index.ts — the 401 handler block (lines 329–351) has both issues and deserves a close second look before merging.

Important Files Changed

Filename Overview
src/index.ts Adds 429 usage-exhaustion failover logic; introduces two issues: syncAuthJson called without try-catch in the 401 path, and getCachedCredentials() swapped for reloadActiveCredentials() which skips OAuth refresh on token expiry.
src/credentials.ts Adds isUsageExhaustionResponse, findAlternativeAccount, reloadActiveCredentials, and getActiveAccountSource; sound logic but findAlternativeAccount can bounce between two exhausted accounts in 3+-account setups.
src/index.test.ts Adds five end-to-end failover integration tests covering happy path, generic-429 no-failover, replacement 401 recovery, persist-on-retry-fail, and sync-throws tolerance.
src/credentials.test.ts Adds unit tests for isUsageExhaustionResponse (five cases) and findAlternativeAccount (single-account null, two-account pick).
CHANGELOG.md Adds Unreleased section documenting the multi-account usage-exhaustion failover fix.
README.md Adds one sentence explaining automatic failover on usage-limit 429 responses.

Comments Outside Diff (1)

  1. src/index.ts, line 304-315 (link)

    P2 Indentation inconsistency introduced in this block

    Lines 306–313 (the headerKeys loop) are indented two extra spaces relative to the surrounding fetch function body. The 401 handler (lines 329–351) and the 429 handler (353–436) are similarly over-indented. This appears to be an artifact of rebasing or a patch applied at the wrong indent level.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/index.ts
    Line: 304-315
    
    Comment:
    **Indentation inconsistency introduced in this block**
    
    Lines 306–313 (the `headerKeys` loop) are indented two extra spaces relative to the surrounding `fetch` function body. The 401 handler (lines 329–351) and the 429 handler (353–436) are similarly over-indented. This appears to be an artifact of rebasing or a patch applied at the wrong indent level.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Cursor Fix in Claude Code Fix in Codex

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/index.ts:329-351
**`syncAuthJson` can throw unhandled in the 401 handler**

The PR added `syncAuthJson(refreshed)` at line 333 without wrapping it in try-catch. `syncAuthJson` propagates I/O errors when writing `auth.json` fails (e.g., disk full, permission denied). If that throw escapes here, the 401 retry is abandoned and the caller receives the exception instead of the response. Every other call to `syncAuthJson` in the new failover path is already wrapped in `try { … } catch { }` for exactly this reason — the 401 handler is the only place that isn't.

### Issue 2 of 4
src/index.ts:329-333
**401 recovery no longer triggers OAuth refresh**

This PR changed the top-level 401 handler from `getCachedCredentials()` to `reloadActiveCredentials()`. `getCachedCredentials()` calls `refreshIfNeeded()``refreshViaOAuth()`, proactively obtaining a new token when the current one is expired. `reloadActiveCredentials()` only reads what is already in the keychain: if the keychain still holds the expired token (no external process refreshed it), `reloadActiveCredentials().accessToken` equals `latest.accessToken`, the condition on line 332 is false, and the retry never fires. A token that expired mid-session would previously recover via OAuth; now it silently fails on 401 unless something else already updated the keychain.

### Issue 3 of 4
src/credentials.ts:130-137
**`findAlternativeAccount` always picks the first non-current account regardless of count**

With three or more locally authenticated accounts, `accounts.find((a) => a.source !== currentSource)` always returns the account at the lowest index that isn't the current one. If that account is also exhausted, the next 429 would call `findAlternativeAccount` again with that account as `currentSource`, returning the first (original, also exhausted) account — creating a ping-pong between two exhausted accounts and never reaching a third.

### Issue 4 of 4
src/index.ts:304-315
**Indentation inconsistency introduced in this block**

Lines 306–313 (the `headerKeys` loop) are indented two extra spaces relative to the surrounding `fetch` function body. The 401 handler (lines 329–351) and the 429 handler (353–436) are similarly over-indented. This appears to be an artifact of rebasing or a patch applied at the wrong indent level.

Reviews (1): Last reviewed commit: "docs: document automatic Claude account ..." | Re-trigger Greptile

Comment thread src/index.ts
Comment on lines 329 to +351
@@ -339,12 +346,97 @@ const plugin: Plugin = async () => {
log("fetch_401_retry_result", {
status: response.status,
modelId,
})
})
}
}

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 syncAuthJson can throw unhandled in the 401 handler

The PR added syncAuthJson(refreshed) at line 333 without wrapping it in try-catch. syncAuthJson propagates I/O errors when writing auth.json fails (e.g., disk full, permission denied). If that throw escapes here, the 401 retry is abandoned and the caller receives the exception instead of the response. Every other call to syncAuthJson in the new failover path is already wrapped in try { … } catch { } for exactly this reason — the 401 handler is the only place that isn't.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/index.ts
Line: 329-351

Comment:
**`syncAuthJson` can throw unhandled in the 401 handler**

The PR added `syncAuthJson(refreshed)` at line 333 without wrapping it in try-catch. `syncAuthJson` propagates I/O errors when writing `auth.json` fails (e.g., disk full, permission denied). If that throw escapes here, the 401 retry is abandoned and the caller receives the exception instead of the response. Every other call to `syncAuthJson` in the new failover path is already wrapped in `try { … } catch { }` for exactly this reason — the 401 handler is the only place that isn't.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment thread src/index.ts
Comment on lines +329 to +333
if (response.status === 401) {
log("fetch_401_retry", { modelId })
const refreshed = reloadActiveCredentials()
if (refreshed && refreshed.accessToken !== latest.accessToken) {
syncAuthJson(refreshed)

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 401 recovery no longer triggers OAuth refresh

This PR changed the top-level 401 handler from getCachedCredentials() to reloadActiveCredentials(). getCachedCredentials() calls refreshIfNeeded()refreshViaOAuth(), proactively obtaining a new token when the current one is expired. reloadActiveCredentials() only reads what is already in the keychain: if the keychain still holds the expired token (no external process refreshed it), reloadActiveCredentials().accessToken equals latest.accessToken, the condition on line 332 is false, and the retry never fires. A token that expired mid-session would previously recover via OAuth; now it silently fails on 401 unless something else already updated the keychain.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/index.ts
Line: 329-333

Comment:
**401 recovery no longer triggers OAuth refresh**

This PR changed the top-level 401 handler from `getCachedCredentials()` to `reloadActiveCredentials()`. `getCachedCredentials()` calls `refreshIfNeeded()``refreshViaOAuth()`, proactively obtaining a new token when the current one is expired. `reloadActiveCredentials()` only reads what is already in the keychain: if the keychain still holds the expired token (no external process refreshed it), `reloadActiveCredentials().accessToken` equals `latest.accessToken`, the condition on line 332 is false, and the retry never fires. A token that expired mid-session would previously recover via OAuth; now it silently fails on 401 unless something else already updated the keychain.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment thread src/credentials.ts
Comment on lines +130 to +137
export function findAlternativeAccount(
currentSource: string | null,
): ClaudeAccount | null {
if (!currentSource) return null

const accounts = refreshAccountsList()
return accounts.find((account) => account.source !== currentSource) ?? null
}

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 findAlternativeAccount always picks the first non-current account regardless of count

With three or more locally authenticated accounts, accounts.find((a) => a.source !== currentSource) always returns the account at the lowest index that isn't the current one. If that account is also exhausted, the next 429 would call findAlternativeAccount again with that account as currentSource, returning the first (original, also exhausted) account — creating a ping-pong between two exhausted accounts and never reaching a third.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/credentials.ts
Line: 130-137

Comment:
**`findAlternativeAccount` always picks the first non-current account regardless of count**

With three or more locally authenticated accounts, `accounts.find((a) => a.source !== currentSource)` always returns the account at the lowest index that isn't the current one. If that account is also exhausted, the next 429 would call `findAlternativeAccount` again with that account as `currentSource`, returning the first (original, also exhausted) account — creating a ping-pong between two exhausted accounts and never reaching a third.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

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.

3 participants