Don't pin the OAuth token to a stale credentials file - #30
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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
isUsableexpiry 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.
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.
Contributor
There was a problem hiding this comment.
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). UsingDate().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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonwhenever it was readable, deliberately skipping theisUsableexpiry 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/403branch that callsclearTokenCaches(), and instead surfaces as a rate limit that will never clear.Observed live on my machine — three different tokens:
~/.claude/.credentials.jsonClaude Code-credentialsConfirmed against the endpoint directly: expired file token →
HTTP 429 rate_limit_error; keychain token →HTTP 200with 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:
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