Skip to content

fix: make OAuth refresh resilient to transient rate-limits (+ diagnostics) - #264

Merged
griffinmartin merged 9 commits into
griffinmartin:mainfrom
anneal-it:fix/oauth-refresh-diagnostics
Aug 3, 2026
Merged

fix: make OAuth refresh resilient to transient rate-limits (+ diagnostics)#264
griffinmartin merged 9 commits into
griffinmartin:mainfrom
anneal-it:fix/oauth-refresh-diagnostics

Conversation

@cdbattags

@cdbattags cdbattags commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

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:

  • refreshViaOAuth returned null for any non-OK response, so a transient
    429 was indistinguishable from a dead refresh token (invalid_grant);
  • it gave up after ~6s, then spawned the claude CLI — which hits the same
    rate-limited endpoint and also fails;
  • it surfaced "Claude Code credentials are unavailable or expired. Run claude" even though the refresh token was still valid;
  • and because the plugin runs in every OpenCode process, N instances (plus
    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-recovered
minutes later once a fresh token reached the keychain.

Fix

Diagnostics. extractOAuthError parses the endpoint's error body into
non-secret oauthError / oauthErrorDescription fields on the refresh_failed
event, so a 429 vs invalid_grant is visible in the debug log.

Transient vs terminal (new refresh-backoff module). Classify refresh
failures. Transient (429/5xx/network/rate_limit_error) means the refresh token
is still good: apply a per-account cooldown with jitter (capped at
MAX_COOLDOWN_MS, including a server Retry-After), adopt a token a sibling
instance 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 real
200 as soon as it clears or a fresh token appears; only on exhaustion do they
return a retryable 429 so 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-lock module). A best-effort
advisory 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. parseOAuthResponse now honors a future absolute
expires_at (ms) in addition to expires_in, so an unusual response can't
mis-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)

  • extractOAuthError guards against non-object JSON bodies (e.g. the literal
    null) that would otherwise throw and erase the HTTP status; flat
    error_description documented as intentionally winning over a nested message.
  • computeBackoffMs clamps a server Retry-After to MAX_COOLDOWN_MS, matching
    the documented cap (a Retry-After: 3600 no longer pins a request for an hour).
  • getCredentialsWithBackoff fast-fails when there is no active account instead
    of spinning the full wait budget.

Known, bounded limitation (intentionally out of scope)

waitForAdopt (used only while another process holds the refresh lock) polls the
store up to LOCK_ADOPT_WAIT_MS (~5s) and does not thread the request's
AbortSignal, 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_at parsing, and the
non-object-body / mixed-error-shape guards.

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.
@griffinmartin

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR makes OAuth token refresh resilient to transient rate-limits (HTTP 429) by introducing two new modules and reworking the refresh path in credentials.ts and index.ts. Previously, any non-OK refresh response was treated as a hard failure and would spawn the claude CLI — which hits the same rate-limited endpoint — and surface a "re-authenticate" error even though the refresh token was still valid.

  • refresh-backoff.ts: Classifies refresh failures as transient (429/5xx/network/rate_limit_error) or terminal (invalid_grant, etc.), records per-account exponential-backoff cooldowns, and allows sibling instances' freshly written tokens to be adopted instead of re-hitting the endpoint during a cooldown.
  • refresh-lock.ts: Best-effort advisory file lock (TTL'd, degrades to no-op on FS errors) to enforce a single refresher across processes, eliminating the thundering-herd burst that provokes 429s in the first place.
  • credentials.ts / index.ts: refreshViaOAuthDetailed replaces the old refreshViaOAuth (kept as a compat wrapper), getCredentialsWithBackoff waits up to 45 s for a cooldown to clear or a sibling to write a fresh token, and the fetch handler returns a retryable 429 synthetic response instead of throwing the hard error when the budget is exhausted transiently.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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
Loading

Reviews (6): Last reviewed commit: "fix: fail fast in getCredentialsWithBack..." | Re-trigger Greptile

Comment thread src/credentials.ts
Comment thread src/credentials.ts
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.
@cdbattags cdbattags changed the title fix: capture the OAuth token endpoint failure reason on refresh fix: make OAuth refresh resilient to transient rate-limits (+ diagnostics) Aug 3, 2026
@cdbattags
cdbattags marked this pull request as ready for review August 3, 2026 15:27
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.
Comment thread src/refresh-backoff.ts
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
Comment thread src/credentials.ts Outdated
… (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.
@cdbattags

Copy link
Copy Markdown
Collaborator Author

Re: the waitForAdopt / AdoptWaitOptions abort-signal note in the review summary — leaving this as-is intentionally. The request AbortSignal reaches getCredentialsWithBackoff but is deliberately not threaded down through getCachedCredentialsrefreshIfNeededwaitForAdopt; that wait is bounded (LOCK_ADOPT_WAIT_MS ~5s) and only entered under cross-process lock contention, so the worst case is a cancelled request noticing up to ~5s late. Plumbing a signal through the whole refresh call chain (and every non-request caller, e.g. the proactive timer) isn't warranted for that ceiling. Documented as a bounded limitation in the PR description for a future hardening pass if desired.

@griffinmartin
griffinmartin merged commit 5532c37 into griffinmartin:main Aug 3, 2026
6 checks passed
griffinmartin pushed a commit that referenced this pull request Aug 3, 2026
🤖 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>
MaxAnderson95 added a commit to MaxAnderson95/opencode-claude-auth that referenced this pull request Aug 4, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants