fix: make OAuth refresh resilient to transient rate-limits (+ diagnostics) - #264
Conversation
refreshViaOAuth() logged only "HTTP <status>" when a token refresh was
rejected, discarding the endpoint's own error body. A persistent 401
(access token rejected, then refresh unable to recover) was therefore
indistinguishable between an expired/revoked refresh token
(invalid_grant), a client mismatch (invalid_client), and transient
rate limiting — all surface to the user as the same opaque "run claude
to re-authenticate".
Parse the token endpoint's error body via a new extractOAuthError()
helper and include the non-secret oauthError / oauthErrorDescription
fields in the refresh_failed debug event. Handles both the OAuth shape
({error, error_description}) and Anthropic's nested API envelope
({error: {type, message}}); values are truncated and the logger still
redacts anything JWT-shaped.
No behavioral change to the refresh flow itself — diagnostics only.
Greptile SummaryThis PR makes OAuth token refresh resilient to transient rate-limits (HTTP 429) by introducing two new modules and reworking the refresh path in
Confidence Score: 5/5Safe to merge — the new modules are well-isolated, degrade gracefully on FS errors, and all previously-flagged review issues have been addressed. The change is additive: existing behaviour (terminal failures, CLI fallback) is preserved, and the new rate-limit path only fires on transient outcomes that previously caused spurious re-auth prompts. The single remaining finding is a bounded latency nuance on request cancellation inside waitForAdopt — no correctness impact. Test coverage is thorough across both new modules and the modified credential-flow paths. Files Needing Attention: src/credentials.ts — specifically the waitForAdopt / AdoptWaitOptions area if abort-signal propagation is later hardened.
|
| Filename | Overview |
|---|---|
| src/refresh-backoff.ts | New module: transient-vs-terminal classification and per-account exponential-backoff cooldown. Well-structured, thorough tests, correctly caps retry-after at MAX_COOLDOWN_MS. |
| src/refresh-lock.ts | New module: best-effort advisory file lock for cross-process single-flight refresh. Two-attempt loop handles stale-takeover, degrades to NOOP on FS errors, uses SHA-256 hash for source → filename mapping. |
| src/credentials.ts | Core changes: refreshViaOAuthDetailed, cooldown-gated refreshIfNeeded, adoptFreshFromSource, getCredentialsWithBackoff, and expires_at parsing. One P2: waitForAdopt default sleep doesn't propagate the outer AbortSignal. |
| src/index.ts | Fetch handler now calls getCredentialsWithBackoff on null credentials and returns a synthetic 429/overloaded_error for transient exhaustion instead of throwing the hard re-auth error. |
| src/credentials.test.ts | Adds tests for extractOAuthError, getCredentialsWithBackoff scenarios, and expires_at parsing; isolates lock dir with mkdtempSync. |
| src/refresh-backoff.test.ts | Comprehensive unit tests covering classification, backoff computation (including retry-after cap regression), cooldown lifecycle, and escalation. |
| src/refresh-lock.test.ts | Unit tests covering mutual exclusion, release, stale takeover, TTL boundary, per-source independence, file cleanup, and degraded-mode grant. |
Sequence Diagram
sequenceDiagram
participant AI as AI SDK / OpenCode
participant idx as index.ts fetch handler
participant gcb as getCredentialsWithBackoff
participant rin as refreshIfNeeded
participant lock as refresh-lock (file)
participant bo as refresh-backoff (in-memory)
participant ep as Token Endpoint
AI->>idx: fetch(request)
idx->>idx: getCachedCredentials() → null
idx->>gcb: "getCredentialsWithBackoff({signal})"
gcb->>rin: (poll loop)
rin->>bo: isRefreshCooldownActive? → false
rin->>lock: acquireRefreshLock(source)
alt Lock acquired
lock-->>rin: RefreshLock handle
rin->>ep: POST /oauth/token
ep-->>rin: 429 rate_limit_error
rin->>bo: noteRefreshTransient → cooldown 15s
rin->>lock: lock.release()
rin-->>gcb: null
loop Poll up to 45s
gcb->>gcb: sleep ~2.5s
gcb->>rin: getCachedCredentials()
rin->>bo: cooldown active → adoptFreshFromSource
rin-->>gcb: freshCredentials
end
gcb-->>idx: freshCredentials
else Lock busy
lock-->>rin: null
rin->>rin: waitForAdopt(5s)
rin-->>gcb: adopted OR null
end
alt Credentials resolved
idx-->>AI: 200 API response
else Transient exhausted
idx-->>AI: 429 overloaded_error
else Terminal
idx-->>AI: Error credentials unavailable
end
Reviews (6): Last reviewed commit: "fix: fail fast in getCredentialsWithBack..." | Re-trigger Greptile
A token that expires while OpenCode is closed must be refreshed on the next request. When the token endpoint rate-limits that refresh (HTTP 429 `rate_limit_error`) the plugin previously treated it as a hard failure: it gave up after ~6s, spawned the `claude` CLI (which hits the same rate-limited endpoint and also fails), and surfaced "credentials unavailable. Run `claude`" — even though the refresh token was still valid. N OpenCode instances refreshing at once turned one rate-limit into a storm. Classify refresh failures (new refresh-backoff module): - transient (429/5xx/network/`rate_limit_error`): the refresh token is still good. Apply a per-account cooldown with jitter, adopt a token a sibling instance or the CLI may have just written to the shared store, and do NOT spawn the CLI. Requests wait through the cooldown (bounded, abort-aware, OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS) via getCredentialsWithBackoff and return a real 200 once it clears; only on exhaustion do they return a retryable 429 so OpenCode/AI-SDK retries instead of showing a hard error. - terminal (`invalid_grant`, ...): the refresh token is dead — keep the existing CLI-fallback / re-auth path. Diagnostics: refresh_transient, refresh_terminal, refresh_cooldown_skip, refresh_adopted_from_source, fetch_credentials_wait, fetch_credentials_transient_exhausted (all redacted).
The plugin runs inside every OpenCode process, so several instances (plus the claude CLI) can all refresh the same expired token at once and bury the token endpoint in duplicate requests, which is what provokes the 429 in the first place. In-process dedup (inFlightRefreshes) cannot see across processes. Add a best-effort advisory lock file (refresh-lock module) keyed by account source. One refresher proceeds; the others wait briefly (waitForAdopt) and adopt the winner's freshly written token from the shared credential store rather than piling on. The lock is best-effort — any filesystem error degrades to refreshing without it — and carries a TTL so a crashed holder's lock is taken over rather than wedging refreshes. The lock directory is OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR-overridable (default the OpenCode data dir) and the TTL is OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS (default 20s). Diagnostics: refresh_lock_acquired / _busy / _stale_takeover / _error.
parseOAuthResponse only read the relative expires_in, defaulting to 10h when absent. If the token endpoint returns an absolute expires_at instead, the plugin would mis-set the access-token lifetime and later 401 on a token it believed valid. Prefer a future millisecond expires_at when present, and fall back to expires_in (or the default) for a missing or seconds-precision value that would otherwise read as already-expired.
A token endpoint returning a JSON primitive or array (e.g. the literal body `null`) made `JSON.parse` succeed with a non-object value, so the subsequent `data.error` dereference threw. That TypeError escaped extractOAuthError into refreshViaOAuthDetailed's outer catch, which then logged the TypeError instead of the real HTTP status — erasing the diagnostic context this path exists to capture. Guard for object bodies after the parse. Also document that the flat OAuth-standard `error_description` intentionally wins over a nested-envelope `message`, with a test for the mixed shape.
computeBackoffMs capped the exponential schedule but returned retryAfterMs uncapped, contradicting the documented 60s cap. A Retry-After: 3600 (which fetchWithRetry passes through once it exceeds the max-retry window) would set a one-hour cooldown, making every request block the full REFRESH_WAIT_MS before returning a 429 for that whole window. Clamp retryAfterMs to MAX_COOLDOWN_MS.
…iagnostics * upstream/main: fix: preserve thinking blocks when repairing tool pairs after compaction (griffinmartin#263) # Conflicts: # README.md
… (review) When getActiveAccount() returned null, `source` was undefined, the terminal-failure early-exit (guarded by `if (source && ...)`) was skipped, and the function spun the full maxWaitMs (~45s) polling getCachedCredentials — which also returns null with no account — before failing. That regressed the previously-immediate hard error into a 45s hang. Return null right away when there is no active account.
|
Re: the |
🤖 I have created a release *beep* *boop* --- ## [2.1.6](v2.1.5...v2.1.6) (2026-08-03) ### Bug Fixes * make OAuth refresh resilient to transient rate-limits (+ diagnostics) ([#264](#264)) ([5532c37](5532c37)) * preserve thinking blocks when repairing tool pairs after compaction ([#263](#263)) ([8de49c8](8de49c8)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Brings in upstream v2.1.5 and v2.1.6: OAuth refresh over the runtime's native fetch instead of a spawned subprocess (griffinmartin#258), external Claude Code credential rotation handling with compare-and-swap writeback and a bounded 401 recovery loop (griffinmartin#260), thinking-block-safe tool-pair repair after compaction (griffinmartin#263), and transient-vs-terminal refresh classification with backoff plus a cross-process refresh lock (griffinmartin#264). Two fork patches are dropped because upstream now solves the same problems better. `resolveJsRuntime` and the OAuth subprocess envelope are gone: upstream griffinmartin#258 removes the subprocess entirely, so there is no `process.execPath` runtime to resolve and the whole failure mode that patch existed for no longer exists. The slow-path credential store re-read in `refreshIfNeeded` is gone too: upstream griffinmartin#260 re-reads every source unconditionally and guards the writeback with a compare-and-swap, which is strictly stronger than re-reading only before spending a refresh. Dead `clearCredentialCache` is removed in favor of upstream's `invalidateCredentialCache`. Five fork patches are retained. Billing identity stays `cli` rather than upstream's `sdk-cli`, in both the user-agent and the `CLAUDE_CODE_ENTRYPOINT` default, which is the reason this fork exists. Expiry timestamps are still floored to integers at the two sinks that write them (`syncToPath` and the auth loader callback), since credentials read straight from the keychain bypass `parseOAuthResponse`'s `Math.trunc`. SSE stream lifecycle instrumentation and the append-only debug log are re-applied on top of upstream's stream transform, which was unchanged by griffinmartin#263. The `claude` CLI is still resolved to an absolute path with stderr captured, because upstream reverted to a bare `execSync("claude ...")` that cannot find the binary under a launchd-managed server's minimal PATH. The child-process test harness is updated for the retained CLI patch: it now rewrites the two-symbol `node:child_process` import, treats a spawned CLI as succeeding rather than throwing (nothing calls `execFileSync` for OAuth anymore), counts POSIX `execFileSync` and Windows `execSync` spawns together via `__getCliSpawnCount`, and pins `CLAUDE_CLI_PATH` so binary resolution never probes the host filesystem. 337 tests pass, typecheck and lint are clean, and the built plugin loads. OpenCode session ID: ses_035a6417cffeukveuaZsQUEJFU
Problem
When the access token expires while OpenCode is closed (overnight, say), the
next request must refresh it. If the token endpoint rate-limits that refresh
(HTTP 429
rate_limit_error) the plugin treated it as a hard failure:refreshViaOAuthreturnednullfor any non-OK response, so a transient429 was indistinguishable from a dead refresh token (
invalid_grant);claudeCLI — which hits the samerate-limited endpoint and also fails;
"Claude Code credentials are unavailable or expired. Runclaude"even though the refresh token was still valid;the CLI) refresh the same token at once — the burst is what provokes the 429.
Captured from a real debug log:
refresh_started(oauth)→fetch_rate_limited 429×N →refresh_failed HTTP 429 rate_limit_error→refresh_fallback_cli→ CLI fails →credentials_unavailable. It self-recoveredminutes later once a fresh token reached the keychain.
Fix
Diagnostics.
extractOAuthErrorparses the endpoint's error body intonon-secret
oauthError/oauthErrorDescriptionfields on therefresh_failedevent, so a 429 vs
invalid_grantis visible in the debug log.Transient vs terminal (new
refresh-backoffmodule). Classify refreshfailures. Transient (429/5xx/network/
rate_limit_error) means the refresh tokenis still good: apply a per-account cooldown with jitter (capped at
MAX_COOLDOWN_MS, including a serverRetry-After), adopt a token a siblinginstance or the CLI just wrote to the shared store, and do not spawn the CLI.
Terminal (
invalid_grant, ...) keeps the existing CLI-fallback / re-auth path.Never a hard error for a passing rate-limit. Requests wait through the
cooldown (bounded, abort-aware —
getCredentialsWithBackoff) and return a real200as soon as it clears or a fresh token appears; only on exhaustion do theyreturn a retryable
429so OpenCode/the AI SDK retries instead of showing the"re-authenticate" error. A missing active account fast-fails immediately rather
than spinning the wait budget.
Single-flight across processes (new
refresh-lockmodule). A best-effortadvisory lock file (per account, TTL'd, degrades to lock-free on any FS error)
lets one refresher hit the endpoint while the others wait briefly and adopt its
result — killing the thundering herd at the source.
Parsing parity.
parseOAuthResponsenow honors a future absoluteexpires_at(ms) in addition toexpires_in, so an unusual response can'tmis-set the token lifetime.
Config (env-overridable, documented in the README)
OPENCODE_CLAUDE_AUTH_REFRESH_WAIT_MS(45000)OPENCODE_CLAUDE_AUTH_REFRESH_COOLDOWN_MS(15000, capped 60000)OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_TTL_MS(20000)OPENCODE_CLAUDE_AUTH_REFRESH_LOCK_DIR(OpenCode data dir)New redacted debug events:
refresh_transient,refresh_terminal,refresh_cooldown_skip,refresh_adopted_from_source,fetch_credentials_wait,fetch_credentials_transient_exhausted,refresh_lock_acquired/_busy/_stale_takeover/_error.Review-round fixes (Greptile)
extractOAuthErrorguards against non-object JSON bodies (e.g. the literalnull) that would otherwise throw and erase the HTTP status; flaterror_descriptiondocumented as intentionally winning over a nestedmessage.computeBackoffMsclamps a serverRetry-AftertoMAX_COOLDOWN_MS, matchingthe documented cap (a
Retry-After: 3600no longer pins a request for an hour).getCredentialsWithBackofffast-fails when there is no active account insteadof spinning the full wait budget.
Known, bounded limitation (intentionally out of scope)
waitForAdopt(used only while another process holds the refresh lock) polls thestore up to
LOCK_ADOPT_WAIT_MS(~5s) and does not thread the request'sAbortSignal, so a request cancelled during lock contention can wait up to~5s before noticing. The wait is bounded and only occurs under cross-process
contention; plumbing a signal through the whole refresh chain isn't warranted
for that ceiling. Flagged for a future hardening pass if desired.
Testing
Full suite green. New unit tests cover classification, cooldown/backoff and the
retry-after cap, the wait-and-adopt path, no-account fast-fail, cross-process
lock contention and stale takeover,
expires_atparsing, and thenon-object-body / mixed-error-shape guards.