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
70 changes: 67 additions & 3 deletions Sources/ClaudeUsageApp/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ struct MenuContentView: View {
.background(Color.primary.opacity(0.06), in: Capsule())
}
}
if let usage = viewModel.usage {
UsageDetailView(usage: usage, creditProjection: viewModel.creditProjection)
} else if authFlow.isAwaitingCode {
if authFlow.isAwaitingCode {
OAuthCodeEntryView(authFlow: authFlow, viewModel: viewModel)
} else if let usage = viewModel.usage {
if let error = viewModel.error {
StaleDataBanner(error: error, authFlow: authFlow) {
Task { await viewModel.refresh() }
}
}
UsageDetailView(usage: usage, creditProjection: viewModel.creditProjection)
} else if let error = viewModel.error {
UsageErrorView(error: error, authFlow: authFlow) {
Task { await viewModel.refresh() }
Expand Down Expand Up @@ -91,3 +96,62 @@ struct MenuContentView: View {
.frame(width: 260)
}
}

/// Inline warning shown above cached usage when the most recent fetch failed.
/// Without this the detail view hides auth/rate-limit errors behind stale data.
private struct StaleDataBanner: View {
let error: UsageError
@ObservedObject var authFlow: AuthFlowState
let onRetry: () -> Void

private var needsReauth: Bool { error.isAuthError }

private var message: String {
switch error {
case .noCredential, .unauthorized: return "Session expired — data may be stale"
case .rateLimited: return "Rate limited — showing cached data"
case .networkError: return "Offline — showing cached data"
case .unknown: return "Can't refresh — showing cached data"
}
}

private var tint: Color {
switch error {
case .noCredential, .unauthorized: return .orange
case .rateLimited: return .yellow
case .networkError, .unknown: return .red
}
}

var body: some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text(message)
.font(.system(size: 10))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 4)
if needsReauth {
Button("Sign in") { authFlow.startFlow() }
.buttonStyle(.borderless)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(tint)
} else {
Button("Retry") { onRetry() }
.buttonStyle(.borderless)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(tint)
}
}
.foregroundStyle(tint)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(tint.opacity(0.3), lineWidth: 0.5)
)
.accessibilityElement(children: .combine)
}
}
7 changes: 7 additions & 0 deletions Sources/ClaudeUsageCore/KeychainReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,11 @@ public enum CredentialStore {
try? keychain.remove("credentials")
isSignedOut = true
}

/// Removes our stored credential without flagging the user as signed out.
/// Used when a refresh attempt proves the stored tokens are dead, so the
/// next read can fall through to the file-based credential sources.
public static func clearStoredCredential() {
try? keychain.remove("credentials")
}
}
19 changes: 18 additions & 1 deletion Sources/ClaudeUsageCore/TokenRefreshingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,16 @@ public final class TokenRefreshingClient: Sendable {
if let refreshed = await refreshOwnToken(credential) {
credential = refreshed
} else {
credential = try resolveCredential()
let reread = try resolveCredential()
// If re-read returns the same (still-expired) token, sending it
// to the API just burns a request on a known-dead credential —
// and the server often responds with 429, trapping us in a
// backoff loop. Surface as unauthorized so the UI can prompt
// re-auth instead.
if reread.accessToken == credential.accessToken && reread.isExpired {
throw TokenRefreshingClientError.unauthorized
}
credential = reread
}
}

Expand Down Expand Up @@ -84,6 +93,14 @@ public final class TokenRefreshingClient: Sendable {
)
// Re-read from store so the credential is fully formed
return credentialProvider()
} catch APIError.unauthorized {
// Refresh token was rejected — drop it so we don't retry forever.
CredentialStore.clearStoredCredential()
return nil
} catch APIError.serverError(let code) where code == 400 {
// 400 on the refresh_token grant means the token itself is invalid.
CredentialStore.clearStoredCredential()
return nil
} catch {
return nil
}
Expand Down
78 changes: 70 additions & 8 deletions Tests/ClaudeUsageTests/TokenRefreshingClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,27 +131,38 @@ struct TokenRefreshingClientTests {

// MARK: - Edge cases

@Test("Provider returns same expired token twice — still calls API")
@Test("Provider returns same expired token twice with no refresh token — fails fast")
func sameExpiredTokenTwice() async throws {
let expiredMs = Int64(Date().timeIntervalSince1970 * 1000) - 1000
let apiCounter = FetchCounter()

let mockSession = MockURLSession { _ in
let mockSession = MockURLSession { request in
apiCounter.increment()
return (self.fixture, HTTPURLResponse(
url: URL(string: "https://api.anthropic.com/api/oauth/usage")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!)
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}

let client = TokenRefreshingClient(
apiClient: AnthropicAPIClient(session: mockSession),
credentialProvider: {
// Always returns expired token — still usable if API accepts it
// Always returns expired token — no refresh token means we can't
// even try to refresh, so the usage call would be wasted.
OAuthCredential.mock(accessToken: "expired", expiresAt: expiredMs)
}
)

// Should still attempt the API call with the expired token
let result = try await client.fetchUsage()
#expect(result.usage.fiveHour?.utilization == 42.0)
do {
_ = try await client.fetchUsage()
Issue.record("Expected unauthorized")
} catch let error as TokenRefreshingClientError {
if case .unauthorized = error {
// Correct — no refresh path and token is expired → bail.
} else {
Issue.record("Expected unauthorized, got \(error)")
}
}

#expect(apiCounter.value == 0)
}

@Test("Fresh token also gets 401 — throws unauthorized")
Expand Down Expand Up @@ -347,4 +358,55 @@ struct TokenRefreshingClientTests {
#expect(apiCounter.value == 3)
#expect(result.usage.fiveHour?.utilization == 42.0)
}

@Test("Expired token + failed refresh + same credential → fails fast as unauthorized")
func expiredTokenFailsFastWhenRefreshDead() async throws {
let expiredMs = Int64(Date().timeIntervalSince1970 * 1000) - 1000
let apiCounter = FetchCounter()

let mockSession = MockURLSession { request in
apiCounter.increment()
let urlPath = request.url?.path ?? ""

// Refresh endpoint returns 400 (refresh token invalid)
if urlPath.contains("/oauth/token") {
return (Data(), HTTPURLResponse(
url: request.url!, statusCode: 400,
httpVersion: nil, headerFields: nil)!)
}

// Should never reach the usage endpoint with a known-dead credential
Issue.record("Should not call usage endpoint when refresh is dead")
return (Data(), HTTPURLResponse(
url: request.url!, statusCode: 429,
httpVersion: nil, headerFields: ["Retry-After": "120"])!)
}

let client = TokenRefreshingClient(
apiClient: AnthropicAPIClient(session: mockSession),
credentialProvider: {
// Always returns the same expired credential — simulates a keychain
// whose refresh token is dead and no file-based fallback exists.
OAuthCredential.mock(
accessToken: "expired",
refreshToken: "dead-refresh",
expiresAt: expiredMs
)
}
)

do {
_ = try await client.fetchUsage()
Issue.record("Expected unauthorized")
} catch let error as TokenRefreshingClientError {
if case .unauthorized = error {
// Correct — we bailed out before hitting the usage endpoint.
} else {
Issue.record("Expected unauthorized, got \(error)")
}
}

// Only the refresh attempt should have been made.
#expect(apiCounter.value == 1)
}
}
Loading