Repository navigation
Description
Summary
On v2.1.6, claude.ai/v1/oauth/token returns 429 rate_limit_error for the plugin's client_id even on the first refresh attempt after hours of inactivity. The plugin then retries every 5 minutes (3 HTTP requests per attempt) until the token expires, surfacing Claude token refresh is rate-limited; retry shortly to the user for ~50 minutes.
Running claude manually refreshes successfully within seconds of a plugin 429 — so the account is not rate-limited, only the plugin's refresh path.
Environment
- opencode-claude-auth 2.1.6
- opencode 1.18.x (bundled in OpenChamber desktop), macOS (arm64), Node 26
- Single opencode instance, single account, credentials in macOS Keychain (
Claude Code-credentials)
Evidence (CLAUDE_AUTH_DEBUG=1)
The plugin made zero token-endpoint calls for the preceding ~7h (token was >1h from expiry, so proactive checks no-op'd). Its first attempt is already 429:
07:58:45 plugin_init {"accountCount":1,"sources":["Claude Code-credentials"]}
... 08:03-16:00: proactive_refresh_check every 5 min, no refresh attempted ...
16:00:52 refresh_needed {"expiresIn":2690520}
16:00:52 refresh_started {"source":"oauth"}
16:00:55 fetch_rate_limited {"status":429,"attempt":1,"retryAfter":"none","delayMs":2000}
16:10:14 refresh_failed {"source":"oauth","error":"The operation was aborted.","kind":"transient"}
Every subsequent 5-minute tick behaves identically:
16:15:14 refresh_started
16:15:14 fetch_rate_limited {"status":429,"attempt":1,"retryAfter":"none","delayMs":2000}
16:15:16 fetch_rate_limited {"status":429,"attempt":2,"retryAfter":"none","delayMs":4000}
16:15:20 refresh_failed {"source":"oauth","error":"HTTP 429","kind":"transient",
"oauthError":"rate_limit_error",
"oauthErrorDescription":"Rate limited. Please try again later."}
16:15:20 refresh_transient {"status":429,"cooldownMs":21139}
…repeating at 16:20, 16:25, 16:30, 16:35, 16:40, 16:45, 16:50, 16:51.
Token expired at 16:45:42; user-visible failures ran from then until 16:52. A manual claude invocation at ~16:52:13 refreshed successfully (new token, +8h); the plugin adopted it from the Keychain ~20s later and immediately returned 200s.
Note retryAfter: "none" on every response — the endpoint sends no Retry-After header.
Issue 1 — the transient cooldown never gates the proactive path
Observed cooldownMs values across the outage:
14565, 21139, 42321, 34792, 35479, 55959, 53016, 57014, 40181, 37519, 41442
All below MAX_COOLDOWN_MS (60s), while the proactive timer runs on SYNC_INTERVAL (5 min). The cooldown has therefore always expired by the next tick and never suppresses an attempt — the escalating backoff in refresh-backoff.ts is effectively inert for the proactive path and only gates the reactive/fetch path.
Issue 2 — each attempt is 3 requests into an endpoint that is already refusing
fetchWithRetry(..., retries = 3) treats 429 as retryable, and with no Retry-After to honour it falls back to the 2s/4s schedule. So each cycle is 1 + 2 retries ≈ 3 requests, ~36 requests/hour while hard-blocked — which plausibly helps sustain the block.
Issue 3 (minor) — duplicate proactive timers
proactive_refresh_check and proactive_refresh_failed each log 4× per tick, with a single plugin_init / auth_loader_called in the same log. The refresh lock does dedupe the real work (refresh_lock_acquired 11 vs refresh_joined 32), so HTTP volume isn't multiplied. I can't tell from the log alone whether this is 4 timers in one process or several processes sharing the file, since the logger truncates on init and would hide earlier inits.
Suggested fixes
- Apply the transient cooldown to the proactive path, and allow it to exceed
SYNC_INTERVAL— e.g. escalate to 15–30 min after repeated 429s instead of capping at 60s. - Don't internally retry a 429 from the token endpoint when no
Retry-Afteris present. One request per cycle is enough; the outer cooldown is the right place to back off. - Reconsider the CLI fallback during sustained transient failure, at least once the token is near/past expiry. It's currently skipped during cooldown on the grounds that it "hits the same endpoint" — but empirically Claude Code's refresh succeeds while the plugin's
client_idis blocked, so it looks like a viable escape hatch rather than a duplicate of the same failing call.
Happy to test a patch or supply the full debug log.
Activity
docs(opencode): document the claude-auth 429 patch
sergioorestes commentedon Sep 3, 2026
Same failure on a different platform and credential source, plus a few measurements that may narrow the cause. Everything below is additive to the report above — the cooldown, the 3-requests-per-cycle amplification and the CLI-fallback question reproduce exactly as described.
Environment: Linux x64, Bun, opencode 1.18.27, file-based credentials (
CLAUDE_CONFIG_DIR), single account, Pro. So this is not keychain- or macOS-specific.1. The limit follows the HTTP client, not the account or the token
Alternated
curland the plugin over a single token lineage no other process knew about:Ruled out by measurement: network/endpoint (both clients answer in ~220 ms), HTTP version (
curl --http1.1also 200), account-level limiting (three interleaved 200s), and a dead grant (an invalid refresh token returns 400invalid_grantto both clients — the 429 only appears when the grant is valid).Hypothesis: the discriminant is request identity.
refreshViaOAuthDetailedsends onlyContent-Type, so the refresh goes out with the raw runtime identity, while API calls carryclaude-cli/<version> (external, sdk-cli)plus the Stainless headers.ANTHROPIC_USER_AGENTdoes not reach the refresh path. Untested on purpose: the only direct test re-redeems a refresh token by hand, which returns 200 and revokes the whole token family — measured here, it cost a full re-login on the affected profile. Sending the API identity on the refresh request would test it safely as a side effect.2. On Linux, a transient 429 has no in-plugin recovery at all
readAllClaudeAccountsonly enumerates a keychain on darwin; on Linux it returns exactly one account fromCLAUDE_CONFIG_DIR. SotryFallbackAccountcan never fire. WithrefreshViaClioutside the transient branch, the only remaining path isadoptFreshFromSource— which needs some other client to have already written a fresh token. Once the current token is expired, nothing inside the plugin can recover. That makes suggested fix 3 above stronger than "worth reconsidering": on Linux it is the only escape that exists.3. The consequence is not only a stalled UI — the run dies
When credentials are gone and the failure is classified transient, the provider
fetchreturns the synthetic429 overloaded_error. The AI SDK retries it ~6 times, ~50 s apart, and then aborts the run:The
claudeCLI refreshed the same profile 20 s after the run aborted, which is what makes the "the account is fine" reading verifiable after the fact.4.
MAX_RETRY_MScannot be used to implement suggested fix 2 locallyOPENCODE_CLAUDE_AUTH_MAX_RETRY_MSgoverns the samefetchWithRetryused by the provider requests inindex.js. Lowering it to stop the token-endpoint retries also disables 429/529 retries for the calls the session pays for, so there is no operator-side way to separate them today — the split has to happen upstream.5. Still present in 2.2.0
Unpacked the published 2.2.0 tarball and diffed it against 2.1.6:
http.js,credentials.js,index.js,keychain.jsandrefresh-backoff.jsare byte-identical. The only changed file ismodel-config.js(ccVersion2.1.217 → 2.1.257 and the effort-beta overrides). Worth updating the issue title's version scope.Mitigations that helped here, for anyone landing on this issue
OPENCODE_CLAUDE_AUTH_REFRESH_COOLDOWN_MS=60000(fewer attempts; does not delay recovery, since the cooldown branch still re-reads the source on every poll),OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS=120000(a request survives long enough for a manualclaudein another terminal to rescue it), and a 15-minute timer that runsclaude -p . --model haikuonly inside the last 45 minutes of token life, so the credential file is always fresh enough foradoptFreshFromSourceto have something to adopt (~3 haiku calls/day/profile).faileon commentedon Sep 7, 2026
Please can we finalize this issue and related PRs? It's really annoying that we have to manually
claude auth loginmultiple times per day :( @griffinmartinphazei commentedon Oct 5, 2026
I'm also experiencing this.
Environment: Windows, opencode-claude-auth 2.2.1, OpenCode 1.18.34, Claude Code 2.1.289, file-based credentials.
The plugin stops authenticating until I open Claude Code and send any prompt; once Claude Code refreshes the token, OpenCode picks it up and works again.
docs(opencode): document the claude-auth 429 patch