diff --git a/CHANGELOG.md b/CHANGELOG.md index e335478df1..8b678884f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - CLI: make `codexbar diagnose` use a generic safe provider diagnostic export for all providers, with MiniMax details attached only as provider-specific metadata. ### Fixed +- Claude: treat OAuth usage HTTP 429s as rate limits, preserve cached credentials, and back off background retries while still allowing manual refresh (#1179). Thanks @LeoLin990405! - Menu bar: stop repeated display-change status-item recreation from corrupting Control Center or confusing menu bar managers (#1176, fixes #1175). Thanks @diazdesandi! ## 0.30.0 — 2026-05-27 diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 10b481bf80..e2c66c07b8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -5,6 +5,7 @@ import FoundationNetworking public enum ClaudeOAuthFetchError: LocalizedError, Sendable { case unauthorized + case rateLimited(retryAfter: Date?) case invalidResponse case serverError(Int, String?) case networkError(Error) @@ -13,6 +14,9 @@ public enum ClaudeOAuthFetchError: LocalizedError, Sendable { switch self { case .unauthorized: return "Claude OAuth request unauthorized. Run `claude` to re-authenticate." + case .rateLimited: + return "Claude OAuth usage endpoint is rate limited by Anthropic right now. Wait a few minutes, " + + "then click Refresh. If it keeps happening, run `claude logout && claude login`, then try again." case .invalidResponse: return "Claude OAuth response was invalid." case let .serverError(code, body): @@ -37,6 +41,10 @@ enum ClaudeOAuthUsageFetcher { private static let fallbackClaudeCodeVersion = "2.1.0" static func fetchUsage(accessToken: String) async throws -> OAuthUsageResponse { + if let blockedUntil = ClaudeOAuthUsageRateLimitGate.blockedUntil() { + throw ClaudeOAuthFetchError.rateLimited(retryAfter: blockedUntil) + } + guard let url = URL(string: baseURL + usagePath) else { throw ClaudeOAuthFetchError.invalidResponse } @@ -56,9 +64,16 @@ enum ClaudeOAuthUsageFetcher { let data = response.data switch response.statusCode { case 200: - return try Self.decodeUsageResponse(data) + let usage = try Self.decodeUsageResponse(data) + ClaudeOAuthUsageRateLimitGate.recordSuccess() + return usage case 401: throw ClaudeOAuthFetchError.unauthorized + case 429: + let retryAfter = Self.retryAfterDate(from: response.response) + ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter) + throw ClaudeOAuthFetchError.rateLimited( + retryAfter: ClaudeOAuthUsageRateLimitGate.currentBlockedUntil() ?? retryAfter) case 403: let body = String(data: data, encoding: .utf8) throw ClaudeOAuthFetchError.serverError(response.statusCode, body) @@ -87,6 +102,23 @@ enum ClaudeOAuthUsageFetcher { return formatter.date(from: string) } + private static func retryAfterDate(from response: HTTPURLResponse, now: Date = Date()) -> Date? { + guard let raw = response.value(forHTTPHeaderField: "Retry-After")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return nil } + + if let seconds = TimeInterval(raw), seconds >= 0 { + return now.addingTimeInterval(seconds) + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss zzz" + return formatter.date(from: raw) + } + private static func claudeCodeUserAgent() -> String { self.claudeCodeUserAgent(versionString: ProviderVersionDetector.claudeVersion()) } @@ -240,5 +272,9 @@ extension ClaudeOAuthUsageFetcher { static func _userAgentForTesting(versionString: String?) -> String { self.claudeCodeUserAgent(versionString: versionString) } + + static func _retryAfterDateForTesting(from response: HTTPURLResponse, now: Date) -> Date? { + self.retryAfterDate(from: response, now: now) + } } #endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift new file mode 100644 index 0000000000..01ee9ec8f9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift @@ -0,0 +1,46 @@ +import Foundation + +enum ClaudeOAuthUsageRateLimitGate { + private static let blockedUntilKey = "claudeOAuthUsageRateLimitBlockedUntilV1" + private static let defaultCooldown: TimeInterval = 60 * 5 + + static func blockedUntil( + interaction: ProviderInteraction = ProviderInteractionContext.current, + now: Date = Date()) -> Date? + { + guard interaction != .userInitiated else { return nil } + return self.currentBlockedUntil(now: now) + } + + static func currentBlockedUntil(now: Date = Date()) -> Date? { + guard let raw = UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double else { + return nil + } + + let blockedUntil = Date(timeIntervalSince1970: raw) + guard blockedUntil > now else { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + return nil + } + return blockedUntil + } + + static func recordRateLimit(retryAfter: Date?, now: Date = Date()) { + let blockedUntil = if let retryAfter, retryAfter > now { + retryAfter + } else { + now.addingTimeInterval(self.defaultCooldown) + } + UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.blockedUntilKey) + } + + static func recordSuccess() { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + + #if DEBUG + static func resetForTesting() { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 7eaac8a1a4..8fd2c4acbc 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -301,6 +301,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { + if case .rateLimited = error { + throw ClaudeUsageError.oauthFailed(error.localizedDescription) + } ClaudeOAuthCredentialsStore.invalidateCache() if case let .serverError(statusCode, body) = error, statusCode == 403, @@ -861,7 +864,12 @@ extension ClaudeUsageFetcher { for outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, retryError: Error) -> String { - _ = retryError + if let oauthError = retryError as? ClaudeOAuthFetchError, + case .rateLimited = oauthError + { + return oauthError.localizedDescription + } + switch outcome { case .skippedByCooldown: return "Claude OAuth token expired and delegated refresh is cooling down. " @@ -895,6 +903,9 @@ extension ClaudeUsageFetcher { switch oauthError { case .unauthorized: metadata["oauthError"] = "unauthorized" + case let .rateLimited(retryAfter): + metadata["oauthError"] = "rateLimited" + metadata["retryAfter"] = retryAfter.map { "\($0.timeIntervalSince1970)" } ?? "nil" case .invalidResponse: metadata["oauthError"] = "invalidResponse" case let .serverError(statusCode, body): diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 6773f40751..e4c399d6e7 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -342,6 +342,107 @@ struct ClaudeOAuthTests { #expect(err.localizedDescription.contains("HTTP 403")) } + @Test + func `O auth429 error gives actionable guidance without raw body`() { + let err = ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + #expect(err.localizedDescription.contains("rate limited")) + #expect(err.localizedDescription.contains("claude logout && claude login")) + #expect(!err.localizedDescription.contains("rate_limit_error")) + } + + @Test + func `O auth429 usage fetch surfaces guidance without raw JSON`() async throws { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + ClaudeOAuthCredentials( + accessToken: "rate-limited-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in + throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + } + + do { + _ = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + } + Issue.record("Expected OAuth rate limit to fail with guidance") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return + } + #expect(message.contains("rate limited")) + #expect(message.contains("claude logout && claude login")) + #expect(!message.contains("rate_limit_error")) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") + } + } + + @Test + func `O auth usage rate limit gate blocks background retries until cooldown`() { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let retryAfter = now.addingTimeInterval(120) + + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == nil) + ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter, now: now) + + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == retryAfter) + #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .background, now: now) == retryAfter) + #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .userInitiated, now: now) == nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(119)) != nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(121)) == nil) + } + + @Test + func `O auth retry after parses seconds`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage")) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 429, + httpVersion: "HTTP/1.1", + headerFields: ["Retry-After": "42"])) + + #expect( + ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now) + == now.addingTimeInterval(42)) + } + + @Test + func `O auth retry after parses HTTP date`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage")) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 429, + httpVersion: "HTTP/1.1", + headerFields: ["Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"])) + + #expect( + ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now) + == Date(timeIntervalSince1970: 1_445_412_480)) + } + @Test func `oauth usage user agent uses claude code version`() { #expect(