From 2daf1c71875d9326a86aeffa34b7f429b0ca4323 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:49:38 +0530 Subject: [PATCH 1/2] menubar: honor Retry-After on Claude quota 429s, back off failed refreshes (#701) --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 96 ++++++++++++++--- .../Data/ClaudeSubscriptionService.swift | 67 +++++++++++- .../Views/HeatmapSection.swift | 13 ++- .../CodeBurnMenubar/Views/SettingsView.swift | 6 +- .../ClaudeRateLimitTests.swift | 100 ++++++++++++++++++ 5 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/ClaudeRateLimitTests.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 34b68d63..2cabaf9d 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI import AppKit import Observation @@ -13,6 +14,25 @@ private let popoverWidth: CGFloat = 360 private let popoverHeight: CGFloat = 660 private let menubarTitleFontSize: CGFloat = 13 +enum SubscriptionRefreshBackoff { + static let initialDelaySeconds: TimeInterval = TimeInterval(refreshIntervalSeconds) + static let maxJitterSeconds: TimeInterval = 5 + + static func delay( + failureCount: Int, + cadence: TimeInterval, + jitterUnit: Double + ) -> TimeInterval { + guard cadence > 0 else { return 0 } + + let exponent = min(max(failureCount, 1) - 1, 10) + let exponential = min(cadence, initialDelaySeconds * pow(2, Double(exponent))) + let normalizedJitter = min(max(jitterUnit, 0), 1) + let jitter = normalizedJitter * min(maxJitterSeconds, cadence) + return min(cadence, exponential + jitter) + } +} + @main struct CodeBurnApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate @@ -436,28 +456,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? + private var claudeQuotaFailureCount = 0 + private var nextClaudeQuotaRefreshAt: Date? @discardableResult - private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool { + private func refreshLiveQuotaProgressIfDue( + force: Bool = false, + forceClaude: Bool = false, + forceCodex: Bool = false + ) async -> Bool { let cadence = SubscriptionRefreshCadence.current - if !force && cadence == .manual { return false } + let autoRefreshAllowed = cadence != .manual + if !force && !forceClaude && !forceCodex && !autoRefreshAllowed { return false } let now = Date() - let threshold = force ? 0 : TimeInterval(cadence.rawValue) - let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold - let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold + let threshold = TimeInterval(cadence.rawValue) + let claudeDue = autoRefreshAllowed && ( + nextClaudeQuotaRefreshAt.map { now >= $0 } ?? + now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold + ) + let shouldRefreshClaude = force || forceClaude || claudeDue + let shouldRefreshCodex = force || forceCodex || ( + autoRefreshAllowed && now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold + ) guard shouldRefreshClaude || shouldRefreshCodex else { return false } + if shouldRefreshClaude { + // The cadence anchor represents the start of an attempt, even if + // the provider fails. Failures get their own shorter backoff below. + lastSubscriptionRefreshAt = now + } + switch (shouldRefreshClaude, shouldRefreshCodex) { case (true, true): async let claude = refreshClaudeQuotaSingleFlight() async let codex = refreshCodexQuotaSingleFlight() - if await claude { lastSubscriptionRefreshAt = Date() } - if await codex { lastCodexRefreshAt = Date() } + let claudeSucceeded = await claude + let codexSucceeded = await codex + finishClaudeQuotaRefresh( + succeeded: claudeSucceeded, + attemptedAt: now, + cadence: threshold + ) + if codexSucceeded { lastCodexRefreshAt = Date() } case (true, false): - if await refreshClaudeQuotaSingleFlight() { - lastSubscriptionRefreshAt = Date() - } + let succeeded = await refreshClaudeQuotaSingleFlight() + finishClaudeQuotaRefresh(succeeded: succeeded, attemptedAt: now, cadence: threshold) case (false, true): if await refreshCodexQuotaSingleFlight() { lastCodexRefreshAt = Date() @@ -468,6 +512,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return true } + private func finishClaudeQuotaRefresh( + succeeded: Bool, + attemptedAt: Date, + cadence: TimeInterval + ) { + guard !succeeded else { + claudeQuotaFailureCount = 0 + nextClaudeQuotaRefreshAt = nil + return + } + + claudeQuotaFailureCount += 1 + let delay = SubscriptionRefreshBackoff.delay( + failureCount: claudeQuotaFailureCount, + cadence: cadence, + jitterUnit: Double.random(in: 0...1) + ) + nextClaudeQuotaRefreshAt = attemptedAt.addingTimeInterval(delay) + } + private func refreshClaudeQuotaSingleFlight() async -> Bool { if let task = claudeQuotaRefreshTask { return await task.value @@ -502,12 +566,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let now = Date() let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) - guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds || - codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return } + let refreshClaude = claudeElapsed >= interactiveQuotaRefreshFloorSeconds + let refreshCodex = codexElapsed >= interactiveQuotaRefreshFloorSeconds + guard refreshClaude || refreshCodex else { return } Task { [weak self] in guard let self else { return } - _ = await self.refreshLiveQuotaProgressIfDue(force: true) + _ = await self.refreshLiveQuotaProgressIfDue( + forceClaude: refreshClaude, + forceCodex: refreshCodex + ) } } @@ -673,6 +741,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil + claudeQuotaFailureCount = 0 + nextClaudeQuotaRefreshAt = nil } private func observeStore() { diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index 4516dd02..d2876d1b 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -153,7 +153,10 @@ enum ClaudeSubscriptionService { throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8)) case 429: let body = String(data: data, encoding: .utf8) - let retryAfter = parseRetryAfter(body: body) + let retryAfter = parseRetryAfter( + header: http.value(forHTTPHeaderField: "Retry-After"), + body: body + ) let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) throw FetchError.rateLimited(retryAt: until) default: @@ -173,13 +176,69 @@ enum ClaudeSubscriptionService { @discardableResult private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date { - let seconds = max(retryAfterSeconds ?? 300, 60) - let until = Date().addingTimeInterval(TimeInterval(seconds)) + let now = Date() + let until = rateLimitBlockUntil( + existingUntil: usageBlockedUntil(), + now: now, + retryAfterSeconds: retryAfterSeconds + ) UserDefaults.standard.set(until, forKey: usageBlockedUntilKey) return until } - private static func parseRetryAfter(body: String?) -> Int? { + static func rateLimitBlockUntil( + existingUntil: Date?, + now: Date, + retryAfterSeconds: Int? + ) -> Date { + let seconds = max(retryAfterSeconds ?? 300, 60) + let newUntil = now.addingTimeInterval(TimeInterval(seconds)) + return max(existingUntil ?? .distantPast, newUntil) + } + + /// Returns the effective Retry-After delay. HTTP headers take precedence + /// over the provider-specific JSON body, with the existing 300-second + /// fallback preserved when neither source is usable. + static func parseRetryAfter( + header: String?, + body: String?, + now: Date = Date() + ) -> Int { + if let seconds = parseRetryAfterHeader(header, now: now) { + return seconds + } + if let seconds = parseRetryAfterBody(body) { + return seconds + } + return 300 + } + + private static func parseRetryAfterHeader(_ value: String?, now: Date) -> Int? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if let seconds = Int(value), seconds >= 0 { + return seconds + } + + let formats = [ + "EEE, dd MMM yyyy HH:mm:ss zzz", + "EEEE, dd-MMM-yy HH:mm:ss zzz", + "EEE MMM d HH:mm:ss yyyy" + ] + for format in formats { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = format + if let date = formatter.date(from: value) { + return max(0, Int(ceil(date.timeIntervalSince(now)))) + } + } + return nil + } + + private static func parseRetryAfterBody(_ body: String?) -> Int? { guard let body, let data = body.data(using: .utf8) else { return nil } if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let n = json["retry_after"] as? Int { return n } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 9f227150..a7e252cf 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI private let trendChartHeight: CGFloat = 90 @@ -1724,14 +1725,14 @@ private struct PlanInsight: View { case .failed: PlanFailedView( error: store.subscriptionError - ) { Task { await store.refreshSubscription() } } + ) { refreshSubscriptionThroughAppDelegate() } case .transientFailure: if let usage { loadedBody(usage: usage) } else { PlanFailedView( error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying." - ) { Task { await store.refreshSubscription() } } + ) { refreshSubscriptionThroughAppDelegate() } } case let .terminalFailure(reason): PlanReconnectView( @@ -1749,6 +1750,14 @@ private struct PlanInsight: View { } } + private func refreshSubscriptionThroughAppDelegate() { + if let delegate = NSApp.delegate as? AppDelegate { + delegate.refreshSubscriptionNow() + } else { + Task { await store.refreshSubscription() } + } + } + @ViewBuilder private func loadedBody(usage: SubscriptionUsage) -> some View { VStack(alignment: .leading, spacing: 10) { diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 494e1f9e..285822a7 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -273,7 +273,11 @@ private struct ClaudeSettingsTab: View { .font(.system(size: 11)) .foregroundStyle(.secondary) Button("Refresh Now") { - Task { await store.refreshSubscription() } + if let delegate = NSApp.delegate as? AppDelegate { + delegate.refreshSubscriptionNow() + } else { + Task { await store.refreshSubscription() } + } } } } diff --git a/mac/Tests/CodeBurnMenubarTests/ClaudeRateLimitTests.swift b/mac/Tests/CodeBurnMenubarTests/ClaudeRateLimitTests.swift new file mode 100644 index 00000000..f653c74b --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ClaudeRateLimitTests.swift @@ -0,0 +1,100 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +final class ClaudeRateLimitTests: XCTestCase { + func testRetryAfterDeltaSeconds() { + XCTAssertEqual( + ClaudeSubscriptionService.parseRetryAfter( + header: " 42 ", + body: #"{"retry_after": 91}"# + ), + 42 + ) + } + + func testRetryAfterHTTPDate() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + XCTAssertEqual( + ClaudeSubscriptionService.parseRetryAfter( + header: "Tue, 14 Nov 2023 22:14:05 GMT", + body: nil, + now: now + ), + 45 + ) + } + + func testMalformedHeaderFallsBackToBody() { + XCTAssertEqual( + ClaudeSubscriptionService.parseRetryAfter( + header: "not-a-retry-after", + body: #"{"retry_after": "91"}"# + ), + 91 + ) + } + + func testMalformedHeaderAndBodyUseDefault() { + XCTAssertEqual( + ClaudeSubscriptionService.parseRetryAfter( + header: "not-a-retry-after", + body: #"{"retry_after": "not-a-number"}"# + ), + 300 + ) + } + + func testRateLimitBlockNeverShrinks() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let existing = now.addingTimeInterval(600) + + XCTAssertEqual( + ClaudeSubscriptionService.rateLimitBlockUntil( + existingUntil: existing, + now: now, + retryAfterSeconds: 60 + ), + existing + ) + XCTAssertEqual( + ClaudeSubscriptionService.rateLimitBlockUntil( + existingUntil: existing, + now: now, + retryAfterSeconds: 900 + ), + now.addingTimeInterval(900) + ) + } + + func testFailureBackoffGrowsAndCapsAtCadence() { + let delays = (1...5).map { + SubscriptionRefreshBackoff.delay( + failureCount: $0, + cadence: 300, + jitterUnit: 0 + ) + } + + XCTAssertEqual(delays, [30, 60, 120, 240, 300]) + XCTAssertEqual(delays, delays.sorted()) + } + + func testFailureBackoffJitterStaysWithinBounds() { + let minimum = SubscriptionRefreshBackoff.delay( + failureCount: 2, + cadence: 300, + jitterUnit: 0 + ) + let maximum = SubscriptionRefreshBackoff.delay( + failureCount: 2, + cadence: 300, + jitterUnit: 1 + ) + + XCTAssertEqual(minimum, 60) + XCTAssertEqual(maximum, 65) + XCTAssertLessThanOrEqual(maximum, 300) + } +} From e36c223e881b2a932a56487a234b618f8d0360ef Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:55:01 +0530 Subject: [PATCH 2/2] Fix operator precedence in claudeDue fallback --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 2cabaf9d..8c18720b 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -473,7 +473,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let threshold = TimeInterval(cadence.rawValue) let claudeDue = autoRefreshAllowed && ( nextClaudeQuotaRefreshAt.map { now >= $0 } ?? - now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold + (now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold) ) let shouldRefreshClaude = force || forceClaude || claudeDue let shouldRefreshCodex = force || forceCodex || (