Skip to content

Don't pin the OAuth token to a stale credentials file - #30

Merged
dmelo merged 2 commits into
mainfrom
fix/stale-file-credential
Aug 13, 2026
Merged

Don't pin the OAuth token to a stale credentials file#30
dmelo merged 2 commits into
mainfrom
fix/stale-file-credential

Conversation

@dmelo

@dmelo dmelo commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Symptom

The popover shows "Usage data is temporarily rate-limited. Try again in a few minutes." and never recovers — no amount of waiting or refreshing helps, and the footer stays on "Not yet updated".

There is no rate limit. The app is sending a token that died hours earlier.

Root cause

readAccessToken() took ~/.claude/.credentials.json whenever it was readable, deliberately skipping the isUsable expiry gate applied to every other source (3d6f1e6). That reasoning held only while the CLI kept rewriting the file.

Once the CLI rotates its keychain copy and stops rewriting the file, the file freezes at an expired token. Because it is checked first and unconditionally, the fresher keychain source below is never reached, and the app is pinned to a dead token indefinitely.

What makes it silent is that the usage endpoint answers a rotated-away token with 429, not 401. So the failure never reaches the 401/403 branch that calls clearTokenCaches(), and instead surfaces as a rate limit that will never clear.

Observed live on my machine — three different tokens:

Source State
~/.claude/.credentials.json expired 22:19, file last written 08:29 the previous afternoon
Keychain Claude Code-credentials valid
what the app was sending the expired file token

Confirmed against the endpoint directly: expired file token → HTTP 429 rate_limit_error; keychain token → HTTP 200 with usage data.

Fix

Gate the file by expiry like every other source, keeping its precedence while it is usable. Read the CLI keychain after the app cache so a usable cache still avoids the permission prompt.

A final pass falls back to whichever token lapsed most recently, which preserves the original intent of 3d6f1e6 — never report a false "no credentials" when a token is merely stale. That fallback is deliberately left out of the in-memory cache, so the next call re-reads every source and picks up a rotation as soon as one lands.

Verification

Reproduced in the real failing state (expired file + fresh keychain), with temporary instrumentation since there are no tests:

readAccessToken called
  file: token=sk-ant-oat01-fZXwXzp exp=2026-08-13 01:19:57 usable=false
  SecItemCopyMatching(service=Claude Code-credentials) status=0 hasData=true
  cli-keychain: token=sk-ant-oat01-_c2qnph usable=true
  -> using CLI KEYCHAIN

Expired file skipped, keychain reached, fresh token used. Instrumentation removed; the clean Release build re-caches the fresh token on launch and the app displays usage again.

Follow-up (not in this PR)

The 429 handler collapses two conditions into one message. A stale-token 429 tells the user to "try again in a few minutes" for something that cannot resolve on its own. Worth distinguishing now that the fall-through works.

🤖 Generated with Claude Code

readAccessToken() took ~/.claude/.credentials.json whenever it was
readable, without the isUsable expiry gate applied to every other
source. That holds only while the CLI keeps rewriting the file. Once it
rotates its keychain copy and leaves the file behind, the file freezes
at an expired token, the fresher keychain source below is never reached,
and the app is pinned to a dead token indefinitely. The usage endpoint
answers a rotated-away token with 429 rather than 401, so the failure
surfaces as "temporarily rate-limited" and never self-heals.

Gate the file by expiry like the other sources, keeping its precedence
while it is usable, and read the CLI keychain only after the app cache
so a usable cache still avoids the prompt. A final pass falls back to
whichever token lapsed most recently, preserving the original intent of
never reporting a false "no credentials" when a token is merely stale.
That fallback is deliberately left out of the in-memory cache so the
next call re-reads every source and picks up a rotation as soon as one
lands.
Copilot AI lite review requested due to automatic review settings August 13, 2026 11:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a persistent “rate-limited”/never-recovering usage fetch scenario by changing OAuthUsageService.readAccessToken() to stop preferring a readable but expired ~/.claude/.credentials.json token over fresher sources, ensuring the app can reach the CLI keychain token after rotations.

Changes:

  • Gate the credentials file token behind the same isUsable expiry check used for other sources.
  • Read the CLI keychain token after the app-owned keychain cache so a usable cache still avoids a permission prompt.
  • Add a final fallback to return the most recently lapsed token (without caching it) rather than claiming there are no credentials.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift Outdated
Review of the fall-through raised three points on the new path.

The expired fallback ignored the app-owned keychain cache, so with no
credentials file and a CLI keychain read that returns nil — a denied
prompt, a renamed item — an expired cached token was skipped and
readAccessToken() returned nil, reinstating the false "no credentials"
the fallback exists to prevent. The cache is now a candidate alongside
the live sources; it can hold the newest token we ever saw.

Declining to cache the fallback meant every call re-ran the whole chain:
a file read and two SecItemCopyMatching calls, one against the CLI's
item, which is the prompt-capable read the app cache exists to avoid.
cachedCredential cannot absorb that, since its gate is isUsable and a
lapsed token never matches. hasCredentials is evaluated inside SwiftUI
bodies — three times in SettingsView, once in ContentView's errorView,
which is the view shown in precisely this state — and those re-run on
every redraw, so the cost was unbounded, and not only in the
all-expired case: the 300s freshness cushion routes every user down it
for the last five minutes of every token's life. Record the outcome of
a sweep that finds nothing usable and reuse it for 30s. The record
covers a nil result too, so the signed-out state is bounded the same
way, and adopt() drops it the moment a live credential appears.

Also reword the keychain comment that read as though this change moved
the app cache ahead of the CLI keychain; that order predates it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Suppressed comments (2)

ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift:117

  • The sweep reuse window check uses -sweep.at.timeIntervalSinceNow, which is easy to misread (and will also treat a future timestamp as eligible for reuse). Using Date().timeIntervalSince(sweep.at) expresses the intent directly and avoids sign confusion.
           -sweep.at.timeIntervalSinceNow < unusableSweepReuseWindow {

ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift:158

  • This comment says “Nothing is unexpired” / “token that lapsed most recently”, but the code is actually checking isUsable (which includes a 5‑minute freshness cushion). At this point a credential may still be unexpired but considered unusable; updating the wording will prevent confusion for future maintainers.
        // Nothing is unexpired, so send the token that lapsed most recently
        // rather than claiming we have no credentials — signed in with a stale
        // token is not the same as signed out, and a request that fails tells the

@dmelo
dmelo merged commit 9e77ef7 into main Aug 13, 2026
1 check passed
@dmelo
dmelo deleted the fix/stale-file-credential branch August 13, 2026 13:53
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