fix: auto-switch Claude accounts after usage exhaustion#132
Conversation
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>
|
Possibley related: can we add support for detecting oauth json files made from this npm library? |
Greptile SummaryThis PR adds automatic account failover when the active Claude account hits a usage-exhaustion 429: it detects the exhaustion conservatively (no
Confidence Score: 3/5The 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.
|
| 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)
-
src/index.ts, line 304-315 (link)Indentation inconsistency introduced in this block
Lines 306–313 (the
headerKeysloop) are indented two extra spaces relative to the surroundingfetchfunction 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!
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
| @@ -339,12 +346,97 @@ const plugin: Plugin = async () => { | |||
| log("fetch_401_retry_result", { | |||
| status: response.status, | |||
| modelId, | |||
| }) | |||
| }) | |||
| } | |||
| } | |||
There was a problem hiding this 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.
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.| if (response.status === 401) { | ||
| log("fetch_401_retry", { modelId }) | ||
| const refreshed = reloadActiveCredentials() | ||
| if (refreshed && refreshed.accessToken !== latest.accessToken) { | ||
| syncAuthJson(refreshed) |
There was a problem hiding this 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.
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.| export function findAlternativeAccount( | ||
| currentSource: string | null, | ||
| ): ClaudeAccount | null { | ||
| if (!currentSource) return null | ||
|
|
||
| const accounts = refreshAccountsList() | ||
| return accounts.find((account) => account.source !== currentSource) ?? null | ||
| } |
There was a problem hiding this 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.
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.
Summary
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
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
retry-afterheader and excluding long-context errorsauth.jsonsync best-effort so fallback-state writes do not block the request pathSafety / non-goals
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
auth.jsonsync throwsTesting