Skip to content

Proactive refresh retries a hard-blocked 429 every 5 min; transient cooldown never applies (v2.1.6) #270

Description

@ricardossilvestre

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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions