372 lines
15 KiB
Swift
372 lines
15 KiB
Swift
import Foundation
|
|
|
|
#if os(macOS)
|
|
import os.lock
|
|
|
|
public enum ClaudeOAuthRefreshFailureGate {
|
|
public enum BlockStatus: Equatable, Sendable {
|
|
case terminal(reason: String?, failures: Int)
|
|
case transient(until: Date, failures: Int)
|
|
}
|
|
|
|
struct AuthFingerprint: Codable, Equatable {
|
|
let keychain: ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint?
|
|
let credentialsFile: String?
|
|
}
|
|
|
|
private struct State {
|
|
var loaded = false
|
|
var terminalFailureCount = 0
|
|
var transientFailureCount = 0
|
|
var isTerminalBlocked = false
|
|
var transientBlockedUntil: Date?
|
|
var fingerprintAtFailure: AuthFingerprint?
|
|
var lastCredentialsRecheckAt: Date?
|
|
var terminalReason: String?
|
|
}
|
|
|
|
private static let lock = OSAllocatedUnfairLock<State>(initialState: State())
|
|
private static let blockedUntilKey = "claudeOAuthRefreshBackoffBlockedUntilV1" // legacy (migration)
|
|
private static let failureCountKey = "claudeOAuthRefreshBackoffFailureCountV1" // legacy + terminal count
|
|
private static let fingerprintKey = "claudeOAuthRefreshBackoffFingerprintV2"
|
|
private static let terminalBlockedKey = "claudeOAuthRefreshTerminalBlockedV1"
|
|
private static let terminalReasonKey = "claudeOAuthRefreshTerminalReasonV1"
|
|
private static let transientBlockedUntilKey = "claudeOAuthRefreshTransientBlockedUntilV1"
|
|
private static let transientFailureCountKey = "claudeOAuthRefreshTransientFailureCountV1"
|
|
|
|
private static let log = CodexBarLog.logger(LogCategories.claudeUsage)
|
|
private static let minimumCredentialsRecheckInterval: TimeInterval = 15
|
|
private static let unknownFingerprint = AuthFingerprint(keychain: nil, credentialsFile: nil)
|
|
private static let transientBaseInterval: TimeInterval = 60 * 5
|
|
private static let transientMaxInterval: TimeInterval = 60 * 60 * 6
|
|
|
|
#if DEBUG
|
|
@TaskLocal static var shouldAttemptOverride: Bool?
|
|
private nonisolated(unsafe) static var fingerprintProviderOverride: (() -> AuthFingerprint?)?
|
|
|
|
static func setFingerprintProviderOverrideForTesting(_ provider: (() -> AuthFingerprint?)?) {
|
|
self.fingerprintProviderOverride = provider
|
|
}
|
|
|
|
public static func resetInMemoryStateForTesting() {
|
|
self.lock.withLock { state in
|
|
state.loaded = false
|
|
state.terminalFailureCount = 0
|
|
state.transientFailureCount = 0
|
|
state.isTerminalBlocked = false
|
|
state.transientBlockedUntil = nil
|
|
state.fingerprintAtFailure = nil
|
|
state.lastCredentialsRecheckAt = nil
|
|
state.terminalReason = nil
|
|
}
|
|
}
|
|
|
|
public static func resetForTesting() {
|
|
self.lock.withLock { state in
|
|
state.loaded = false
|
|
state.terminalFailureCount = 0
|
|
state.transientFailureCount = 0
|
|
state.isTerminalBlocked = false
|
|
state.transientBlockedUntil = nil
|
|
state.fingerprintAtFailure = nil
|
|
state.lastCredentialsRecheckAt = nil
|
|
state.terminalReason = nil
|
|
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
|
|
UserDefaults.standard.removeObject(forKey: self.failureCountKey)
|
|
UserDefaults.standard.removeObject(forKey: self.fingerprintKey)
|
|
UserDefaults.standard.removeObject(forKey: self.terminalBlockedKey)
|
|
UserDefaults.standard.removeObject(forKey: self.terminalReasonKey)
|
|
UserDefaults.standard.removeObject(forKey: self.transientBlockedUntilKey)
|
|
UserDefaults.standard.removeObject(forKey: self.transientFailureCountKey)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public static func shouldAttempt(now: Date = Date()) -> Bool {
|
|
#if DEBUG
|
|
if let override = self.shouldAttemptOverride { return override }
|
|
#endif
|
|
|
|
return self.lock.withLock { state in
|
|
let didMigrate = self.loadIfNeeded(&state, now: now)
|
|
if didMigrate {
|
|
self.persist(state)
|
|
}
|
|
|
|
if state.isTerminalBlocked {
|
|
guard self.shouldRecheckCredentials(now: now, state: state) else { return false }
|
|
|
|
state.lastCredentialsRecheckAt = now
|
|
if self.hasCredentialsChangedSinceFailure(state) {
|
|
self.resetState(&state)
|
|
self.persist(state)
|
|
return true
|
|
}
|
|
|
|
self.log.debug(
|
|
"Claude OAuth refresh blocked until auth changes",
|
|
metadata: [
|
|
"terminalFailures": "\(state.terminalFailureCount)",
|
|
"reason": state.terminalReason ?? "nil",
|
|
])
|
|
return false
|
|
}
|
|
|
|
if let blockedUntil = state.transientBlockedUntil {
|
|
if blockedUntil <= now {
|
|
self.clearTransientState(&state)
|
|
// Once transient backoff expires, forget its auth baseline so future failures capture fresh
|
|
// fingerprints and so we don't ratchet backoff across unrelated intermittent failures.
|
|
state.fingerprintAtFailure = nil
|
|
state.lastCredentialsRecheckAt = nil
|
|
self.persist(state)
|
|
return true
|
|
}
|
|
|
|
if self.shouldRecheckCredentials(now: now, state: state) {
|
|
state.lastCredentialsRecheckAt = now
|
|
if self.hasCredentialsChangedSinceFailure(state) {
|
|
self.resetState(&state)
|
|
self.persist(state)
|
|
return true
|
|
}
|
|
}
|
|
|
|
self.log.debug(
|
|
"Claude OAuth refresh transient backoff active",
|
|
metadata: [
|
|
"until": "\(blockedUntil.timeIntervalSince1970)",
|
|
"transientFailures": "\(state.transientFailureCount)",
|
|
])
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
public static func currentBlockStatus(now: Date = Date()) -> BlockStatus? {
|
|
self.lock.withLock { state in
|
|
_ = self.loadIfNeeded(&state, now: now)
|
|
if state.isTerminalBlocked {
|
|
return .terminal(reason: state.terminalReason, failures: state.terminalFailureCount)
|
|
}
|
|
if let blockedUntil = state.transientBlockedUntil, blockedUntil > now {
|
|
return .transient(until: blockedUntil, failures: state.transientFailureCount)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public static func recordTerminalAuthFailure(now: Date = Date()) {
|
|
self.lock.withLock { state in
|
|
_ = self.loadIfNeeded(&state, now: now)
|
|
state.terminalFailureCount += 1
|
|
state.isTerminalBlocked = true
|
|
state.terminalReason = "invalid_grant"
|
|
state.fingerprintAtFailure = self.currentFingerprint() ?? self.unknownFingerprint
|
|
state.lastCredentialsRecheckAt = now
|
|
self.clearTransientState(&state)
|
|
self.persist(state)
|
|
}
|
|
}
|
|
|
|
public static func recordTransientFailure(now: Date = Date()) {
|
|
self.lock.withLock { state in
|
|
_ = self.loadIfNeeded(&state, now: now)
|
|
|
|
// Keep terminal blocking monotonic: once we know auth is rejected (e.g. invalid_grant),
|
|
// do not downgrade it to time-based backoff unless auth changes (fingerprint) or we record success.
|
|
guard !state.isTerminalBlocked else { return }
|
|
|
|
self.clearTerminalState(&state)
|
|
|
|
state.transientFailureCount += 1
|
|
let interval = self.transientCooldownInterval(failures: state.transientFailureCount)
|
|
state.transientBlockedUntil = now.addingTimeInterval(interval)
|
|
state.fingerprintAtFailure = self.currentFingerprint() ?? self.unknownFingerprint
|
|
state.lastCredentialsRecheckAt = now
|
|
self.persist(state)
|
|
}
|
|
}
|
|
|
|
public static func recordAuthFailure(now: Date = Date()) {
|
|
// Legacy shim: treat as terminal auth failure.
|
|
self.recordTerminalAuthFailure(now: now)
|
|
}
|
|
|
|
public static func recordSuccess() {
|
|
self.lock.withLock { state in
|
|
_ = self.loadIfNeeded(&state, now: Date())
|
|
self.resetState(&state)
|
|
self.persist(state)
|
|
}
|
|
}
|
|
|
|
private static func shouldRecheckCredentials(now: Date, state: State) -> Bool {
|
|
guard let last = state.lastCredentialsRecheckAt else { return true }
|
|
return now.timeIntervalSince(last) >= self.minimumCredentialsRecheckInterval
|
|
}
|
|
|
|
private static func hasCredentialsChangedSinceFailure(_ state: State) -> Bool {
|
|
guard let current = self.currentFingerprint() else { return false }
|
|
guard let prior = state.fingerprintAtFailure else { return false }
|
|
return current != prior
|
|
}
|
|
|
|
private static func currentFingerprint() -> AuthFingerprint? {
|
|
#if DEBUG
|
|
if let override = self.fingerprintProviderOverride { return override() }
|
|
#endif
|
|
return AuthFingerprint(
|
|
keychain: ClaudeOAuthCredentialsStore.currentClaudeKeychainFingerprintWithoutPromptForAuthGate(),
|
|
credentialsFile: ClaudeOAuthCredentialsStore.currentCredentialsFileFingerprintWithoutPromptForAuthGate())
|
|
}
|
|
|
|
private static func loadIfNeeded(_ state: inout State, now: Date) -> Bool {
|
|
state.loaded = true
|
|
var didMutate = false
|
|
|
|
// Always refresh persisted fields from UserDefaults, even after first load.
|
|
//
|
|
// This avoids stale state when UserDefaults are modified while the app is running (or during tests),
|
|
// while still keeping ephemeral throttling state (like lastCredentialsRecheckAt) in memory.
|
|
state.terminalFailureCount = UserDefaults.standard.integer(forKey: self.failureCountKey)
|
|
state.transientFailureCount = UserDefaults.standard.integer(forKey: self.transientFailureCountKey)
|
|
|
|
if let raw = UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) as? Double {
|
|
state.transientBlockedUntil = Date(timeIntervalSince1970: raw)
|
|
}
|
|
|
|
let legacyBlockedUntil = (UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double)
|
|
.map { Date(timeIntervalSince1970: $0) }
|
|
let legacyFailureCount = UserDefaults.standard.integer(forKey: self.failureCountKey)
|
|
|
|
if let data = UserDefaults.standard.data(forKey: self.fingerprintKey) {
|
|
state.fingerprintAtFailure = (try? JSONDecoder().decode(AuthFingerprint.self, from: data))
|
|
} else {
|
|
state.fingerprintAtFailure = nil
|
|
}
|
|
|
|
if UserDefaults.standard.object(forKey: self.terminalBlockedKey) != nil {
|
|
state.isTerminalBlocked = UserDefaults.standard.bool(forKey: self.terminalBlockedKey)
|
|
state.terminalReason = UserDefaults.standard.string(forKey: self.terminalReasonKey)
|
|
if legacyBlockedUntil != nil {
|
|
didMutate = true
|
|
}
|
|
} else {
|
|
// Migration: legacy keys represented a time-based backoff. Migrate to transient backoff (never terminal)
|
|
// unless we already have new transient keys persisted.
|
|
if UserDefaults.standard.object(forKey: self.transientFailureCountKey) == nil,
|
|
UserDefaults.standard.object(forKey: self.transientBlockedUntilKey) == nil,
|
|
legacyBlockedUntil != nil || legacyFailureCount > 0
|
|
{
|
|
state.isTerminalBlocked = false
|
|
state.terminalReason = nil
|
|
state.terminalFailureCount = 0
|
|
|
|
if let legacyBlockedUntil, legacyBlockedUntil > now {
|
|
state.transientFailureCount = max(legacyFailureCount, 0)
|
|
state.transientBlockedUntil = legacyBlockedUntil
|
|
} else {
|
|
state.transientFailureCount = 0
|
|
state.transientBlockedUntil = nil
|
|
}
|
|
didMutate = true
|
|
}
|
|
}
|
|
|
|
if state.isTerminalBlocked || state.transientBlockedUntil != nil, state.fingerprintAtFailure == nil {
|
|
state.fingerprintAtFailure = self.unknownFingerprint
|
|
didMutate = true
|
|
}
|
|
|
|
if legacyBlockedUntil != nil {
|
|
didMutate = true
|
|
}
|
|
|
|
return didMutate
|
|
}
|
|
|
|
private static func persist(_ state: State) {
|
|
UserDefaults.standard.set(state.terminalFailureCount, forKey: self.failureCountKey)
|
|
UserDefaults.standard.set(state.isTerminalBlocked, forKey: self.terminalBlockedKey)
|
|
if let reason = state.terminalReason {
|
|
UserDefaults.standard.set(reason, forKey: self.terminalReasonKey)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: self.terminalReasonKey)
|
|
}
|
|
|
|
UserDefaults.standard.set(state.transientFailureCount, forKey: self.transientFailureCountKey)
|
|
if let blockedUntil = state.transientBlockedUntil {
|
|
UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.transientBlockedUntilKey)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: self.transientBlockedUntilKey)
|
|
}
|
|
|
|
UserDefaults.standard.removeObject(forKey: self.blockedUntilKey)
|
|
|
|
if let fingerprint = state.fingerprintAtFailure,
|
|
let data = try? JSONEncoder().encode(fingerprint)
|
|
{
|
|
UserDefaults.standard.set(data, forKey: self.fingerprintKey)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: self.fingerprintKey)
|
|
}
|
|
}
|
|
|
|
private static func transientCooldownInterval(failures: Int) -> TimeInterval {
|
|
guard failures > 0 else { return 0 }
|
|
let factor = pow(2.0, Double(failures - 1))
|
|
return min(self.transientBaseInterval * factor, self.transientMaxInterval)
|
|
}
|
|
|
|
private static func clearTerminalState(_ state: inout State) {
|
|
state.terminalFailureCount = 0
|
|
state.isTerminalBlocked = false
|
|
state.terminalReason = nil
|
|
}
|
|
|
|
private static func clearTransientState(_ state: inout State) {
|
|
state.transientFailureCount = 0
|
|
state.transientBlockedUntil = nil
|
|
}
|
|
|
|
private static func resetState(_ state: inout State) {
|
|
self.clearTerminalState(&state)
|
|
self.clearTransientState(&state)
|
|
state.fingerprintAtFailure = nil
|
|
state.lastCredentialsRecheckAt = nil
|
|
}
|
|
}
|
|
#else
|
|
public enum ClaudeOAuthRefreshFailureGate {
|
|
public enum BlockStatus: Equatable, Sendable {
|
|
case terminal(reason: String?, failures: Int)
|
|
case transient(until: Date, failures: Int)
|
|
}
|
|
|
|
public static func shouldAttempt(now _: Date = Date()) -> Bool {
|
|
true
|
|
}
|
|
|
|
public static func currentBlockStatus(now _: Date = Date()) -> BlockStatus? {
|
|
nil
|
|
}
|
|
|
|
public static func recordTerminalAuthFailure(now _: Date = Date()) {}
|
|
|
|
public static func recordTransientFailure(now _: Date = Date()) {}
|
|
|
|
public static func recordAuthFailure(now _: Date = Date()) {}
|
|
|
|
public static func recordSuccess() {}
|
|
|
|
#if DEBUG
|
|
static func setFingerprintProviderOverrideForTesting(_: (() -> Any?)?) {}
|
|
public static func resetInMemoryStateForTesting() {}
|
|
public static func resetForTesting() {}
|
|
#endif
|
|
}
|
|
#endif
|