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
96 changes: 83 additions & 13 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import SwiftUI
import AppKit
import Observation
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}
}

Expand Down Expand Up @@ -673,6 +741,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
func resetSubscriptionCadenceAnchor() {
lastSubscriptionRefreshAt = nil
lastCodexRefreshAt = nil
claudeQuotaFailureCount = 0
nextClaudeQuotaRefreshAt = nil
}

private func observeStore() {
Expand Down
67 changes: 63 additions & 4 deletions mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }
Expand Down
13 changes: 11 additions & 2 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

private let trendChartHeight: CGFloat = 90
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}
}
}
Expand Down
100 changes: 100 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/ClaudeRateLimitTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}