Fix usage stuck after OAuth token rotation; keep data on transient failures#20
Open
dmelo wants to merge 2 commits into
Open
Fix usage stuck after OAuth token rotation; keep data on transient failures#20dmelo wants to merge 2 commits into
dmelo wants to merge 2 commits into
Conversation
The app cached the OAuth access token in its own keychain with no expiry and
read that cache before the live Claude Code source. Claude Code rotates the
token roughly every 8h, and a rotated-away token returns HTTP 429 (not 401)
from /api/oauth/usage — so the 401-based cache self-heal never fired and the
app got stuck sending a dead token, showing a permanent rate-limit error.
- Cache {accessToken, expiresAt} and trust it only until ~5 min before expiry,
then re-read the live source to pick up the rotated token.
- Store the cache as JSON so an older bare-string cache fails to parse and is
transparently replaced (auto-migration on first launch of this build).
- Map HTTP 429 to a distinct UsageError.rateLimited instead of the generic
"Invalid response from API."
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A transient fetch failure (notably the usage endpoint's own shared rate limit) set the error state, and ContentView checked error before webUsage — so a single blip wiped every card for a full-screen error even when valid recent data was in hand. - ViewModel clears the error only on a successful fetch, otherwise keeping the last good data. - ContentView is now data-first: it shows the cached usage and, on a failed refresh, a small banner instead of replacing everything. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR improves reliability and UX for Claude Code usage fetching by keeping last-known data visible during transient failures, handling API rate-limiting explicitly, and making OAuth token caching aware of token expiry/rotation.
Changes:
- Update UI to prefer showing existing usage data and display refresh errors as a subtle banner.
- Add
rateLimitederror and treat HTTP 429 distinctly. - Replace raw token caching with an
{accessToken, expiresAt}credential cache to avoid stale rotated tokens.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| ClaudeCodeStats/ClaudeCodeStats/UsageViewModel.swift | Adjusts error clearing to preserve last-good data during refresh failures |
| ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift | Adds expiry-aware credential caching and handles HTTP 429 rate limiting |
| ClaudeCodeStats/ClaudeCodeStats/Models.swift | Adds UsageError.rateLimited with user-facing description |
| ClaudeCodeStats/ClaudeCodeStats/ContentView.swift | Shows stale-data banner when refresh fails but prior usage exists |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+116
to
+122
| // Live keychain source owned by the Claude Code CLI. Re-reading here is | ||
| // what picks up a token the CLI has rotated; cache the result (in the new | ||
| // format, with expiry) so we don't prompt on every fetch. | ||
| if let cred = readCredentialFromKeychain(service: keychainService) { | ||
| saveCredentialToAppKeychain(cred) | ||
| cachedCredential = cred | ||
| return cred.token |
Comment on lines
42
to
54
| func refresh() async { | ||
| isLoading = true | ||
| error = nil | ||
|
|
||
| do { | ||
| let usage = try await OAuthUsageService.shared.fetchUsage() | ||
| webUsage = usage | ||
| error = nil | ||
| UsageHistoryService.shared.record(usage) | ||
| } catch { | ||
| // Keep the last good data on screen. When webUsage exists, ContentView | ||
| // shows a subtle banner instead of replacing everything with an error. | ||
| self.error = error.localizedDescription | ||
| } |
Comment on lines
+149
to
+164
| private func staleBanner(_ message: String) -> some View { | ||
| HStack(alignment: .top, spacing: 6) { | ||
| Image(systemName: "exclamationmark.triangle.fill") | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(.orange) | ||
|
|
||
| Text(message) | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(Theme.textSecondary) | ||
| .fixedSize(horizontal: false, vertical: true) | ||
|
|
||
| Spacer(minLength: 0) | ||
| } | ||
| .padding(.horizontal, 12) | ||
| .padding(.top, 12) | ||
| } |
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.
Problem
The menu bar showed a persistent error (first "Invalid response from API.", then "temporarily rate-limited") and stopped updating for ~9 hours. The usage-history file confirmed 20,750 successful fetches, then a hard stop — the signature of a stale credential, not a network blip.
Root cause
OAuthUsageServicecached the OAuth access token in the app's own keychain (to avoid repeated permission prompts) with no expiry, and read that cache before the liveClaude Code-credentialssource. Claude Code rotates the access token about every 8 hours (claudeAiOauth.expiresAt). Once it rotated, the app kept sending the stale token forever.The trap that hid it: a rotated-away token returns HTTP 429, not 401 from
/api/oauth/usage(verified in isolation on a fully-reset rate-limit budget — stale token → 429, fresh token → 200). So the app's 401-based cache self-heal never fired.Separately, the usage endpoint has its own rate limit (~5 rapid calls → 429 with
retry-after~281s), shared with the Claude Code CLI and claude.ai — and a 429 was wiping all on-screen data.Fixes
Token rotation (
Refresh rotated OAuth token; handle usage 429 distinctly){accessToken, expiresAt}; trust it only until ~5 min before expiry (clock-skew cushion), then re-read the live source to pick up the rotated token.UsageError.rateLimitedinstead of the generic invalid-response error.Transient-failure resilience (
Keep last usage visible when a refresh fails)Testing
session 20% / weekly 84% / Fable 96%) — recovering the 9-hour outage. Confirmed live in the menu bar.🤖 Generated with Claude Code