From 1b76666bbac10b7575b3a7248c05cd24fcab0bcd Mon Sep 17 00:00:00 2001 From: Diogo Oliveira de Melo Date: Thu, 9 Jul 2026 09:45:30 -0300 Subject: [PATCH 1/2] Refresh rotated OAuth token; handle usage 429 distinctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ClaudeCodeStats/ClaudeCodeStats/Models.swift | 3 + .../Services/OAuthUsageService.swift | 123 +++++++++++++----- 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/ClaudeCodeStats/ClaudeCodeStats/Models.swift b/ClaudeCodeStats/ClaudeCodeStats/Models.swift index 6b01f13..a82ec55 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/Models.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/Models.swift @@ -35,6 +35,7 @@ enum UsageError: Error, LocalizedError { case networkError(Error) case invalidResponse case tokenExpired + case rateLimited var errorDescription: String? { switch self { @@ -46,6 +47,8 @@ enum UsageError: Error, LocalizedError { return "Invalid response from API." case .tokenExpired: return "OAuth token expired. Run 'claude' to re-authenticate." + case .rateLimited: + return "Usage data is temporarily rate-limited. Try again in a few minutes." } } } diff --git a/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift b/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift index 6b861f6..94e84e5 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift @@ -13,9 +13,27 @@ class OAuthUsageService { private let keychainService = "Claude Code-credentials" private let appKeychainService = "ClaudeCodeStats-credentials" private let appKeychainAccount = "oauth-token" - private var cachedToken: String? + private var cachedCredential: Credential? private let session: URLSession + // An OAuth access token plus the expiry the CLI recorded for it. Tracking + // expiry lets us notice when the CLI has rotated the token and re-read the + // live source instead of clinging to a stale cached copy. + private struct Credential { + let token: String + let expiresAt: Date? + + // Treat a token as usable until shortly before it expires, so we never + // send one that's about to lapse (a rotated-away token returns 429, not + // 401, so we can't rely on a failed request to tell us it's stale). The + // 5-minute cushion absorbs clock skew between us and the server. + // Unknown expiry = usable. + var isUsable: Bool { + guard let expiresAt else { return true } + return expiresAt.timeIntervalSinceNow > 300 + } + } + private init() { let config = URLSessionConfiguration.default config.waitsForConnectivity = true @@ -61,6 +79,13 @@ class OAuthUsageService { throw UsageError.tokenExpired } + // The usage endpoint has its own rate limit (HTTP 429 with a long + // retry-after and no usage body). Surface it distinctly so the UI keeps + // showing the last known data and recovers on the next scheduled poll. + if httpResponse.statusCode == 429 { + throw UsageError.rateLimited + } + guard (200...299).contains(httpResponse.statusCode) else { throw UsageError.invalidResponse } @@ -69,40 +94,54 @@ class OAuthUsageService { } private func readAccessToken() -> String? { - if let token = cachedToken { - return token + // In-memory cache, but only while the token is still fresh. + if let cached = cachedCredential, cached.isUsable { + return cached.token } - if let token = readTokenFromFile() { - cachedToken = token - return token + + // Live file source (present on some setups), authoritative when readable. + if let cred = readCredentialFromFile(), cred.isUsable { + cachedCredential = cred + return cred.token } - if let token = readTokenFromAppKeychain() { - cachedToken = token - return token + + // App-owned keychain cache — avoids repeated permission prompts on the + // CLI's item. Trust it only while unexpired; a token that has rotated out + // is skipped so we fall through and re-read the live source below. + if let cred = readCredentialFromAppKeychain(), cred.isUsable { + cachedCredential = cred + return cred.token } - if let token = readTokenFromKeychain(service: keychainService) { - saveTokenToAppKeychain(token) - cachedToken = token - return token + + // 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 } + return nil } private func clearTokenCaches() { - cachedToken = nil + cachedCredential = nil deleteAppKeychainItem() } - private func readTokenFromFile() -> String? { - guard let data = FileManager.default.contents(atPath: credentialsPath), - let token = extractToken(from: data) else { + private func readCredentialFromFile() -> Credential? { + guard let data = FileManager.default.contents(atPath: credentialsPath) else { return nil } - return token + return extractCredential(from: data) } - // App keychain stores the raw token string, not the JSON credential blob - private func readTokenFromAppKeychain() -> String? { + // The app keychain stores our own {accessToken, expiresAt} JSON so we can + // tell when a cached token has rotated out. A value written by an older build + // (a bare token string) won't parse and is treated as absent — so the app + // transparently re-reads the live source and re-caches in the new format. + private func readCredentialFromAppKeychain() -> Credential? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: appKeychainService, @@ -114,16 +153,13 @@ class OAuthUsageService { var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let data = result as? Data, - let token = String(data: data, encoding: .utf8), - !token.isEmpty else { + guard status == errSecSuccess, let data = result as? Data else { return nil } - return token + return decodeCachedCredential(from: data) } - private func readTokenFromKeychain(service: String) -> String? { + private func readCredentialFromKeychain(service: String) -> Credential? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -134,16 +170,18 @@ class OAuthUsageService { var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let data = result as? Data, - let token = extractToken(from: data) else { + guard status == errSecSuccess, let data = result as? Data else { return nil } - return token + return extractCredential(from: data) } - private func saveTokenToAppKeychain(_ token: String) { - guard let data = token.data(using: .utf8) else { return } + private func saveCredentialToAppKeychain(_ credential: Credential) { + var payload: [String: Any] = ["accessToken": credential.token] + if let expiresAt = credential.expiresAt { + payload["expiresAt"] = expiresAt.timeIntervalSince1970 + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } // Delete existing item first (if any) deleteAppKeychainItem() @@ -157,7 +195,7 @@ class OAuthUsageService { ] let status = SecItemAdd(query as CFDictionary, nil) if status != errSecSuccess { - NSLog("OAuthUsageService: Failed to cache token in app keychain (status: \(status))") + NSLog("OAuthUsageService: Failed to cache credential in app keychain (status: \(status))") } } @@ -173,14 +211,29 @@ class OAuthUsageService { } } - private func extractToken(from data: Data) -> String? { + // Parses the {accessToken, expiresAt} JSON this app writes to its own keychain. + private func decodeCachedCredential(from data: Data) -> Credential? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = json["accessToken"] as? String, !token.isEmpty else { + return nil + } + let expiresAt = (json["expiresAt"] as? NSNumber) + .map { Date(timeIntervalSince1970: $0.doubleValue) } + return Credential(token: token, expiresAt: expiresAt) + } + + // Parses the Claude Code credential blob (claudeAiOauth.accessToken/expiresAt). + private func extractCredential(from data: Data) -> Credential? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let oauth = json["claudeAiOauth"] as? [String: Any], let token = oauth["accessToken"] as? String, !token.isEmpty else { return nil } - return token + // expiresAt is epoch milliseconds. + let expiresAt = (oauth["expiresAt"] as? NSNumber) + .map { Date(timeIntervalSince1970: $0.doubleValue / 1000) } + return Credential(token: token, expiresAt: expiresAt) } // Shape of the /api/oauth/usage JSON response (only the fields we consume). From 823df24229333b17d76da817fa3855f5f4727b08 Mon Sep 17 00:00:00 2001 From: Diogo Oliveira de Melo Date: Thu, 9 Jul 2026 09:45:31 -0300 Subject: [PATCH 2/2] Keep last usage visible when a refresh fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ClaudeCodeStats/ContentView.swift | 29 ++++++++++++++++--- .../ClaudeCodeStats/UsageViewModel.swift | 4 ++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift b/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift index d4fa282..01b99c6 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift @@ -75,11 +75,15 @@ struct ContentView: View { Divider() .background(Theme.divider) - // Content - if let error = viewModel.error { - errorView(error) - } else if let usage = viewModel.webUsage { + // Content — data-first: a transient refresh failure shows a subtle + // banner above the last known usage rather than wiping it. + if let usage = viewModel.webUsage { + if let error = viewModel.error { + staleBanner(error) + } usageView(usage) + } else if let error = viewModel.error { + errorView(error) } else { loadingView } @@ -142,6 +146,23 @@ struct ContentView: View { .padding(12) } + 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) + } + private func errorView(_ error: String) -> some View { VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") diff --git a/ClaudeCodeStats/ClaudeCodeStats/UsageViewModel.swift b/ClaudeCodeStats/ClaudeCodeStats/UsageViewModel.swift index 908cce2..f57ba04 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/UsageViewModel.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/UsageViewModel.swift @@ -41,13 +41,15 @@ class UsageViewModel: ObservableObject { 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 }