Skip to content

Fix usage stuck after OAuth token rotation; keep data on transient failures#20

Open
dmelo wants to merge 2 commits into
mainfrom
fix/stale-token-and-usage-resilience
Open

Fix usage stuck after OAuth token rotation; keep data on transient failures#20
dmelo wants to merge 2 commits into
mainfrom
fix/stale-token-and-usage-resilience

Conversation

@dmelo

@dmelo dmelo commented Jul 9, 2026

Copy link
Copy Markdown
Owner

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

OAuthUsageService cached the OAuth access token in the app's own keychain (to avoid repeated permission prompts) with no expiry, and read that cache before the live Claude Code-credentials source. 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)

  • Cache {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.
  • Store the cache as JSON so an older bare-string cache fails to parse and is transparently replaced — the fix self-migrates on first launch.
  • Map HTTP 429 to a distinct UsageError.rateLimited instead of the generic invalid-response error.

Transient-failure resilience (Keep last usage visible when a refresh fails)

  • ViewModel clears the error only on a successful fetch, keeping the last good data otherwise.
  • ContentView is data-first: it keeps showing cached usage and, on a failed refresh, a small banner instead of replacing everything with a full-screen error.

Testing

  • Reproduced the stuck state; confirmed stale token → 429 and fresh token → 200 in isolation.
  • Built and installed the fix: the app ignored its stale cache, re-read the rotated token, fetched 200, and wrote a fresh history snapshot (session 20% / weekly 84% / Fable 96%) — recovering the 9-hour outage. Confirmed live in the menu bar.

🤖 Generated with Claude Code

dmelo and others added 2 commits July 9, 2026 09:45
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>
Copilot AI review requested due to automatic review settings July 9, 2026 12:46

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

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 rateLimited error 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)
}
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