Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 12 additions & 1 deletion Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. "
Expand Down Expand Up @@ -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):
Expand Down
101 changes: 101 additions & 0 deletions Tests/CodexBarTests/ClaudeOAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down