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-After is 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_id is 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.
Summary
On v2.1.6,
claude.ai/v1/oauth/tokenreturns429 rate_limit_errorfor the plugin'sclient_ideven 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, surfacingClaude token refresh is rate-limited; retry shortlyto the user for ~50 minutes.Running
claudemanually refreshes successfully within seconds of a plugin 429 — so the account is not rate-limited, only the plugin's refresh path.Environment
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:
Every subsequent 5-minute tick behaves identically:
…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
claudeinvocation 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 noRetry-Afterheader.Issue 1 — the transient cooldown never gates the proactive path
Observed
cooldownMsvalues across the outage:All below
MAX_COOLDOWN_MS(60s), while the proactive timer runs onSYNC_INTERVAL(5 min). The cooldown has therefore always expired by the next tick and never suppresses an attempt — the escalating backoff inrefresh-backoff.tsis 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 noRetry-Afterto 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_checkandproactive_refresh_failedeach log 4× per tick, with a singleplugin_init/auth_loader_calledin the same log. The refresh lock does dedupe the real work (refresh_lock_acquired11 vsrefresh_joined32), 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
SYNC_INTERVAL— e.g. escalate to 15–30 min after repeated 429s instead of capping at 60s.Retry-Afteris present. One request per cycle is enough; the outer cooldown is the right place to back off.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.