diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index c5599df..e6cbf82 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -67,8 +67,12 @@ export type AccountStorage = { quota?: { enabled?: boolean checkIntervalMinutes?: number + refreshEveryNRequests?: number minimumRemaining?: Partial> failClosedOnUnknownQuota?: boolean + mainQuota?: OAuthQuotaSnapshot + mainQuotaCheckedAt?: number + mainLastQuotaApiError?: AccountOperationError } claudeCache?: { enabled?: boolean @@ -113,6 +117,7 @@ export type AccountManagerOptions = { now?: () => number fetchImpl?: typeof fetch configPath?: string + quotaManager?: import('./quota-manager.ts').QuotaManager } export type AccountRefreshError = { @@ -127,6 +132,9 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000 const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000 const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000 +const MIN_QUOTA_RETRY_DELAY_MS = 60_000 +const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000 +const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000 const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5 const DEFAULT_MINIMUM_REMAINING: Record = { five_hour: 0, @@ -608,12 +616,95 @@ export function formatRefreshBackoffMessage( return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}` } +function isTransientQuotaError(error: unknown): boolean { + if (!(error instanceof Error)) return false + // fetchOAuthQuotaSnapshot throws: "Claude quota check failed: {status} — {body}" + const statusMatch = error.message.match(/Claude quota check failed: (\d+)/) + if (statusMatch) { + const status = Number(statusMatch[1]) + return status === 429 || status >= 500 + } + // Network errors + return ( + error.message.includes('fetch failed') || + ('code' in error && + (error.code === 'ECONNRESET' || + error.code === 'ECONNREFUSED' || + error.code === 'ETIMEDOUT' || + error.code === 'UND_ERR_CONNECT_TIMEOUT')) + ) +} + +export function buildQuotaOperationError(input: { + error: unknown + now: number + previous?: AccountOperationError +}): AccountOperationError { + const previousRetryCount = input.previous?.retryCount ?? 0 + const retryCount = previousRetryCount + 1 + const delay = isTransientQuotaError(input.error) + ? Math.min( + MAX_QUOTA_RETRY_DELAY_MS, + MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6), + ) + : NON_TRANSIENT_QUOTA_RETRY_DELAY_MS + return { + message: formatErrorMessage(input.error), + checkedAt: input.now, + nextRetryAt: input.now + delay, + retryCount, + } +} + +export function quotaBackoffActive( + error: AccountOperationError | undefined, + now: number, +): boolean { + if (!error?.nextRetryAt || error.nextRetryAt <= now) return false + return true +} + +export function formatQuotaBackoffMessage( + error: AccountOperationError, + now: number, +): string { + const seconds = Math.max( + 1, + Math.ceil(((error.nextRetryAt ?? now) - now) / 1000), + ) + return `Quota API backed off for ${seconds}s after: ${error.message}` +} + export function getQuotaCheckIntervalMs(storage: AccountStorage | null) { const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES return Math.max(1, minutes) * 60_000 } +export function getPersistedMainQuota( + storage: AccountStorage | null, +): { quota: OAuthQuotaSnapshot; checkedAt: number } | null { + if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt) + return null + return { + quota: storage.quota.mainQuota, + checkedAt: storage.quota.mainQuotaCheckedAt, + } +} + +/** + * How often (in requests) to force a quota refresh, independent of the timer. + * Returns 0 when disabled (default). + */ +export function getQuotaRefreshEveryNRequests( + storage: AccountStorage | null, +): number { + const n = storage?.quota?.refreshEveryNRequests + return typeof n === 'number' && Number.isFinite(n) && n > 0 + ? Math.floor(n) + : 0 +} + function failClosedOnUnknownQuota(storage: AccountStorage | null) { return ( storage?.quota?.failClosedOnUnknownQuota ?? @@ -764,10 +855,11 @@ function recordQuotaRefreshError( error: unknown, now: number, ) { - account.lastQuotaRefreshError = { - message: formatErrorMessage(error), - checkedAt: now, - } + account.lastQuotaRefreshError = buildQuotaOperationError({ + error, + now, + previous: account.lastQuotaRefreshError, + }) if (error instanceof ClaudeOAuthRefreshError) { recordRefreshError(account, error, now) } @@ -780,11 +872,37 @@ export class FallbackAccountManager { private readonly refreshPromises = new Map>() private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null + readonly quotaManager: import('./quota-manager.ts').QuotaManager | null constructor(options: AccountManagerOptions = {}) { this.now = options.now ?? Date.now this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() + this.quotaManager = options.quotaManager ?? null + } + + /** + * Seed QuotaManager from persisted account.quota if no cache entry exists + * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh. + */ + private seedFallbackQuota( + account: OAuthAccount, + storage: AccountStorage, + ): void { + if (!this.quotaManager) return + if (this.quotaManager.getFallback(account.id)) return + if (!account.quota) return + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) return + const checkInterval = getQuotaCheckIntervalMs(storage) + this.quotaManager.setFallback(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) } async load() { @@ -840,7 +958,11 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (quotaIsStale(next, storage, this.now())) { + this.seedFallbackQuota(next, storage) + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (stale) { next = await this.refreshAccountQuota(next, storage) changed = true } @@ -937,7 +1059,16 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (!quotaIsStale(next, storage, this.now())) continue + if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) { + continue + } + this.seedFallbackQuota(next, storage) + // Use QuotaManager staleness when available (shared cache); + // fall back to per-account on-disk staleness otherwise. + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (!stale) continue await this.refreshAccountQuota(next, storage) changed = true } catch (error) { @@ -1085,6 +1216,15 @@ export class FallbackAccountManager { } target.lastQuotaRefreshError = undefined updateStoredAccount(storage, target) + // Sync to shared QuotaManager so all consumers see the same cache + if (this.quotaManager && target.quota) { + const now = this.now() + this.quotaManager.setFallback(target.id, { + quota: target.quota, + refreshAfter: now + getQuotaCheckIntervalMs(storage), + checkedAt: now, + }) + } return target } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9ae5fe..ce93e01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,5 +9,6 @@ export * from './dump.ts' export * from './fast.ts' export * from './logger.ts' export * from './pkce.ts' +export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts new file mode 100644 index 0000000..e6b72f4 --- /dev/null +++ b/packages/core/src/quota-manager.ts @@ -0,0 +1,390 @@ +/** + * Unified quota cache and API gateway. + * + * Single source of truth for main + fallback quota state. All consumers + * share one QuotaManager instance so they see the same in-memory cache. + * Handles deduplication, rate-limiting (429 backoff), and staleness. + */ + +import type { + AccountOperationError, + AccountStorage, + OAuthAccount, + OAuthQuotaSnapshot, +} from './accounts.ts' +import { + acquireRefreshFileLock, + buildQuotaOperationError, + fetchOAuthQuotaSnapshot, + getPersistedMainQuota, + getQuotaCheckIntervalMs, + getQuotaNextRefreshAt, + getQuotaRefreshEveryNRequests, + quotaBackoffActive, +} from './accounts.ts' + +// Capture real setTimeout before tests can mock globalThis.setTimeout +const nativeSetTimeout = globalThis.setTimeout + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type QuotaEntry = { + quota: OAuthQuotaSnapshot + refreshAfter: number // Unix ms — earliest next refresh + checkedAt: number // when snapshot was fetched +} + +export type QuotaManagerOptions = { + storage: AccountStorage | null + fetchImpl?: typeof fetch + now?: () => number + onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number) => void + onApiError?: (error: AccountOperationError) => void +} + +// --------------------------------------------------------------------------- +// Class +// --------------------------------------------------------------------------- + +export class QuotaManager { + // --- State --- + private main: QuotaEntry | null = null + private mainAccessToken: string | null = null + private fallbacks = new Map() + + // --- Inflight deduplication --- + private inflightMain: Promise | null = null + private inflightFallbacks = new Map>() + + // --- Rate-limiting --- + private lastApiError: AccountOperationError | undefined = undefined + + // --- Serial API gate (prevents concurrent quota API calls) --- + private apiGate: Promise = Promise.resolve() + private lastApiCallAt = 0 + + // --- Config --- + private storage: AccountStorage | null + private readonly fetchImpl: typeof fetch + private readonly now: () => number + private readonly onMainQuotaFetched: QuotaManagerOptions['onMainQuotaFetched'] + private readonly onApiError: QuotaManagerOptions['onApiError'] + + constructor(opts: QuotaManagerOptions) { + this.storage = opts.storage + this.fetchImpl = opts.fetchImpl ?? fetch + this.now = opts.now ?? Date.now + this.onMainQuotaFetched = opts.onMainQuotaFetched + this.onApiError = opts.onApiError + + // Seed main quota from persisted storage + const persisted = getPersistedMainQuota(opts.storage) + if (persisted) { + this.main = { + quota: persisted.quota, + refreshAfter: + persisted.checkedAt + getQuotaCheckIntervalMs(opts.storage), + checkedAt: persisted.checkedAt, + } + } + + // Seed backoff state from persisted storage + const persistedError = opts.storage?.quota?.mainLastQuotaApiError + if (persistedError && quotaBackoffActive(persistedError, this.now())) { + this.lastApiError = persistedError + } + } + + // ========================================================================= + // Get (synchronous, from cache) + // ========================================================================= + + getMain(): QuotaEntry | null { + return this.main + } + + getFallback(accountId: string): QuotaEntry | null { + return this.fallbacks.get(accountId) ?? null + } + + getAllFallbacks(): Map { + return this.fallbacks + } + + // ========================================================================= + // Set (manual inject — seeding from persisted account.quota on boot) + // ========================================================================= + + setMain(accessToken: string, entry: QuotaEntry): void { + this.mainAccessToken = accessToken + this.main = entry + } + + setFallback(accountId: string, entry: QuotaEntry): void { + this.fallbacks.set(accountId, entry) + } + + // ========================================================================= + // Refresh (async, deduplicated, rate-limited) + // ========================================================================= + + async refreshMain(accessToken: string): Promise { + // If token changed, invalidate cache + if (this.mainAccessToken && this.mainAccessToken !== accessToken) { + this.main = null + this.mainAccessToken = null + } + + // Deduplicate — return in-flight promise if already fetching + if (this.inflightMain) return this.inflightMain + + // Rate-limit — if API recently 429'd, return stale or throw + if (this.isBackedOff()) { + if (this.main) return this.main.quota + throw new Error('Quota API rate-limited — try again later') + } + + this.inflightMain = this._fetchMain(accessToken) + return this.inflightMain + } + + async refreshFallback( + accountId: string, + accessToken: string, + ): Promise { + // Deduplicate + const inflight = this.inflightFallbacks.get(accountId) + if (inflight) return inflight + + // Rate-limit + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + + const promise = this._fetchFallback(accountId, accessToken) + this.inflightFallbacks.set(accountId, promise) + return promise + } + + async refreshAllFallbacks(accounts: OAuthAccount[]): Promise { + const now = this.now() + + for (const account of accounts) { + if (account.enabled === false) continue + if (!account.access) continue + + const cached = this.fallbacks.get(account.id) + if (cached && now < cached.refreshAfter) continue + + try { + await this.refreshFallback(account.id, account.access) + } catch { + // Best-effort — keep stale cache entry if fetch fails + } + } + } + + /** + * Fire-and-forget refresh. Does not await, swallows errors. + */ + refreshMainInBackground(accessToken: string): void { + if (this.inflightMain) return + if (this.isBackedOff()) return + void this.refreshMain(accessToken).catch(() => {}) + } + + // ========================================================================= + // Staleness queries + // ========================================================================= + + isMainStale(): boolean { + if (!this.main) return true + return this.now() >= this.main.refreshAfter + } + + isFallbackStale(accountId: string): boolean { + const entry = this.fallbacks.get(accountId) + if (!entry) return true + return this.now() >= entry.refreshAfter + } + + shouldRefreshOnRequestCount(requestCount: number): boolean { + const everyN = getQuotaRefreshEveryNRequests(this.storage) + if (everyN <= 0) return false + return requestCount > 0 && requestCount % everyN === 0 + } + + /** + * Combined check: should a refresh happen right now? + * True if main is stale by time OR triggered by request count. + */ + needsRefresh(requestCount: number): boolean { + return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount) + } + + // ========================================================================= + // Config + // ========================================================================= + + updateStorage(storage: AccountStorage | null): void { + this.storage = storage + } + + /** + * Seed fallback cache entries from persisted account.quota data. + * Only seeds accounts that don't already have a cache entry. + * Prevents unnecessary API calls when persisted quota is still fresh. + */ + seedFallbacksFromAccounts(accounts: OAuthAccount[]): void { + const checkInterval = getQuotaCheckIntervalMs(this.storage) + for (const account of accounts) { + if (account.enabled === false) continue + if (this.fallbacks.has(account.id)) continue + if (!account.quota) continue + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) continue + this.fallbacks.set(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) + } + } + + /** + * Whether the API is currently in backoff due to a recent error. + */ + isBackedOff(): boolean { + return quotaBackoffActive(this.lastApiError, this.now()) + } + + getLastApiError(): AccountOperationError | undefined { + return this.lastApiError + } + + // ========================================================================= + // Private + // ========================================================================= + + /** Minimum gap between consecutive quota API calls (ms). */ + private static readonly API_CALL_GAP_MS = 1_000 + + /** + * Serialize API calls through a shared gate so only one + * quota API request runs at a time, with a minimum gap + * between calls. Prevents concurrent and rapid-fire calls + * from triggering Anthropic's rate limits. + */ + private _enqueueApiFetch(fn: () => Promise): Promise { + const gatedFn = async (): Promise => { + // Wait until minimum gap since last API call + const elapsed = this.now() - this.lastApiCallAt + if (elapsed < QuotaManager.API_CALL_GAP_MS) { + await new Promise((r) => { + const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed) + if (typeof id === 'object' && 'unref' in id) id.unref() + }) + } + this.lastApiCallAt = this.now() + return fn() + } + const queued = this.apiGate.then(gatedFn, gatedFn) + this.apiGate = queued.catch(() => {}) + return queued + } + + private async _fetchMain(accessToken: string): Promise { + return this._enqueueApiFetch(async () => { + try { + // Re-check backoff inside gate — may have been set by + // a preceding queued call while we waited + if (this.isBackedOff()) { + if (this.main) return this.main.quota + throw new Error('Quota API rate-limited — try again later') + } + const fileLock = await acquireRefreshFileLock({ + name: 'opencode-main-quota-refresh', + ttlMs: 30_000, + }) + if (!fileLock) { + if (this.main) return this.main.quota + throw new Error('Quota refresh is already in progress') + } + try { + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.mainAccessToken = accessToken + this.main = { + quota, + refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now), + checkedAt: now, + } + this.lastApiError = undefined + this.onMainQuotaFetched?.(quota, now) + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + await fileLock.release() + } + } finally { + this.inflightMain = null + } + }) + } + + private async _fetchFallback( + accountId: string, + accessToken: string, + ): Promise { + return this._enqueueApiFetch(async () => { + try { + // Re-check backoff inside gate + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.fallbacks.set(accountId, { + quota, + refreshAfter: now + getQuotaCheckIntervalMs(this.storage), + checkedAt: now, + }) + this.lastApiError = undefined + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + this.inflightFallbacks.delete(accountId) + } + }) + } + + private _handleFetchError(error: unknown): void { + this.lastApiError = buildQuotaOperationError({ + error, + now: this.now(), + previous: this.lastApiError, + }) + this.onApiError?.(this.lastApiError) + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c8561e..3b3eb52 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,13 +21,12 @@ import { executeDumpCommand, executeFastModeCommand, FallbackAccountManager, - fetchOAuthQuotaSnapshot, + formatQuotaBackoffMessage, formatRefreshBackoffMessage, + getAccountStoragePath, getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, - getQuotaCheckIntervalMs, - getQuotaNextRefreshAt, getRelayConfig, hashRefreshToken, isCache1hEnabled, @@ -47,6 +46,7 @@ import { parseDumpCommandAction, parseFastModeCommandAction, type QuotaAccountSummary, + QuotaManager, quotaSnapshotPassesPolicy, type RelayConfig, refreshBackoffActive, @@ -86,12 +86,6 @@ const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES -type MainQuotaCache = { - accessToken: string - refreshAfter: number - quota: OAuthQuotaSnapshot -} - type NotificationRequest = { path: { id: string } body: { @@ -247,11 +241,50 @@ function throwHandledSentinel(): never { export const AnthropicAuthPlugin: Plugin = async (ctx) => { startEventLoopLagMonitor() const { client } = ctx - const fallbackManager = new FallbackAccountManager() + const accountStoragePath = getAccountStoragePath() + const initialStorage = await loadAccounts(accountStoragePath) + const quotaManager = new QuotaManager({ + storage: initialStorage, + onMainQuotaFetched: async (quota, checkedAt) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainQuota = quota + storage.quota.mainQuotaCheckedAt = checkedAt + storage.quota.mainLastQuotaApiError = undefined + await saveAccounts(storage, accountStoragePath) + } catch (error) { + log('[quota] failed to persist main quota', { + error: error instanceof Error ? error.message : String(error), + }) + } + }, + onApiError: async (error) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainLastQuotaApiError = error + await saveAccounts(storage, accountStoragePath) + } catch (e) { + log('[quota] failed to persist backoff state', { + error: e instanceof Error ? e.message : String(e), + }) + } + }, + }) + const fallbackManager = new FallbackAccountManager({ + quotaManager, + }) fallbackManager.startBackgroundRefresh() let latestRefreshMainAccessToken: (() => Promise) | null = null const cacheKeepManager = new CacheKeepManager({ - loadStorage: () => loadAccounts(), + loadStorage: () => loadAccounts(accountStoragePath), prepareHeaders: async (headers, target) => { if (!latestGetAuth) return headers const auth = await latestGetAuth() @@ -289,14 +322,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, log, }) - const initialCache1hStorage = await loadAccounts() - const relayConfig: RelayConfig | null = getRelayConfig(initialCache1hStorage) + const relayConfig: RelayConfig | null = getRelayConfig(initialStorage) setCache1hState({ - enabled: isCache1hPersistentlyEnabled(initialCache1hStorage), - mode: getCache1hPersistentMode(initialCache1hStorage), + enabled: isCache1hPersistentlyEnabled(initialStorage), + mode: getCache1hPersistentMode(initialStorage), }) - setDumpEnabled(isDumpPersistentlyEnabled(initialCache1hStorage)) - setFastModeEnabled(isFastModePersistentlyEnabled(initialCache1hStorage)) + setDumpEnabled(isDumpPersistentlyEnabled(initialStorage)) + setFastModeEnabled(isFastModePersistentlyEnabled(initialStorage)) let latestGetAuth: | (() => Promise<{ type: string @@ -324,7 +356,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function clearStaleMainRefreshError(refreshToken?: string) { if (!refreshToken) return - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const error = storage?.refresh?.mainLastRefreshError if (!storage?.refresh || !error?.tokenHash) return const tokenHash = hashRefreshToken(refreshToken) @@ -347,10 +379,15 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { try { const auth = await latestGetAuth() if (auth.type === 'oauth' && auth.access) { + // Use QuotaManager cache; eager fetch on first request means + // this is always populated after the first API call. + const cached = quotaManager.getMain() + const quota = + cached?.quota ?? (await quotaManager.refreshMain(auth.access)) accounts.push({ name: 'OpenCode anthropic', role: 'main', - quota: await fetchOAuthQuotaSnapshot({ accessToken: auth.access }), + quota, }) } else if (auth.type === 'oauth') { accounts.push({ @@ -361,22 +398,34 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } } catch (error) { + const msg = error instanceof Error ? error.message : String(error) accounts.push({ name: 'OpenCode anthropic', role: 'main', - error: error instanceof Error ? error.message : String(error), + error: msg.includes('429') + ? 'Usage API rate limited — try again in a moment' + : msg, }) } } - const { storage, errors } = - await fallbackManager.refreshQuotaForAllAccounts() - accounts.push( - ...buildFallbackQuotaSummaries( - storage, - new Map(errors.map((error) => [error.accountId, error.message])), - ), + // Use QuotaManager for fallbacks — goes through serial API gate + const storage = await loadAccounts(accountStoragePath) + const fallbackAccts = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false && a.access, ) + try { + await quotaManager.refreshAllFallbacks(fallbackAccts) + } catch { + // Best-effort — stale cache is fine for display + } + // Overlay QuotaManager cache onto storage accounts for display + const fallbackEntries = quotaManager.getAllFallbacks() + for (const account of storage?.accounts ?? []) { + const cached = fallbackEntries.get(account.id) + if (cached) account.quota = cached.quota + } + accounts.push(...buildFallbackQuotaSummaries(storage, new Map())) if (!latestGetAuth) { accounts.unshift({ @@ -410,7 +459,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isCache1hPersistentlyEnabled(storage) const mode = getCache1hPersistentMode(storage) setCache1hState({ enabled, mode }) @@ -419,7 +468,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function executePersistentCacheKeepCommand(argumentsText: string) { const action = parseCacheKeepCommandAction(argumentsText) - let storage = await loadAccounts() + let storage = await loadAccounts(accountStoragePath) if (action.type === 'window') { storage = await setCacheKeepPersistentWindow( action.startHour, @@ -450,7 +499,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeDumpCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isDumpPersistentlyEnabled(storage) setDumpEnabled(enabled) return executeDumpCommand({ argumentsText, enabled }) @@ -465,12 +514,116 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeFastModeCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isFastModePersistentlyEnabled(storage) setFastModeEnabled(enabled) return executeFastModeCommand({ argumentsText, enabled }) } + function quotaBar(pct: number, width = 16): string { + const filled = Math.min(Math.round((pct / 100) * width), width) + return '█'.repeat(filled) + '░'.repeat(width - filled) + } + + function formatResetIn(resetsAt: string | undefined): string { + if (!resetsAt) return '' + const ms = new Date(resetsAt).getTime() - Date.now() + if (ms <= 0) return 'resets now' + const mins = Math.floor(ms / 60_000) + if (mins < 60) return `resets ${mins}m` + const hrs = Math.floor(mins / 60) + const rm = mins % 60 + return rm > 0 ? `resets ${hrs}h${rm}m` : `resets ${hrs}h` + } + + function showQuotaToast( + quota: OAuthQuotaSnapshot | null, + fallbacks?: Array<{ + id: string + label?: string + quota?: OAuthQuotaSnapshot + }>, + activeAccountId?: string, + ) { + const sections: string[] = [] + let globalMaxUsed = 0 + + // Main account + if (quota) { + const fh = quota.five_hour + const sd = quota.seven_day + if (fh || sd) { + const mainActive = activeAccountId === 'main' + const indicator = mainActive ? '🟢' : ' ' + const reset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} main${reset ? ` (${reset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + // Fallback accounts + if (fallbacks?.length) { + for (const fb of fallbacks) { + const q = fb.quota + if (!q) continue + const fh = q.five_hour + const sd = q.seven_day + if (!fh && !sd) continue + const name = fb.label || 'alt' + const fbActive = activeAccountId === fb.id + const indicator = fbActive ? '🟢' : ' ' + const fbReset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} ${name}${fbReset ? ` (${fbReset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + if (!sections.length) return + const message = sections.join('\n\n') + const variant = + globalMaxUsed >= 90 ? 'error' : globalMaxUsed >= 70 ? 'warning' : 'info' + + // biome-ignore lint/suspicious/noExplicitAny: SDK client.tui type not exposed to server plugins + void (client.tui as any) + ?.showToast?.({ + body: { + title: 'Claude Quota', + message, + variant, + duration: variant === 'error' ? 8000 : 5000, + }, + }) + ?.catch?.(() => {}) + } + return { config: async (config: { command?: Record }) => { config.command = { @@ -581,9 +734,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Shared inflight refresh promise — prevents concurrent token refreshes // from racing against each other (and causing 401 cascades with token rotation) let refreshPromise: Promise | null = null - let mainQuotaCache: MainQuotaCache | null = null - let mainQuotaRefreshPromise: Promise | null = null - let mainQuotaRetryAfter = 0 async function refreshMainAccessToken() { if (!refreshPromise) { @@ -597,7 +747,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function updateMainRefreshState( update: (storage: AccountStorage) => void, ) { - const storage: AccountStorage = (await loadAccounts()) ?? { + const storage: AccountStorage = (await loadAccounts( + accountStoragePath, + )) ?? { version: 1, main: { type: 'opencode', provider: 'anthropic' }, accounts: [], @@ -665,7 +817,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const refreshTokenHash = hashRefreshToken(freshAuth.refresh) const mainError = storage?.refresh?.mainLastRefreshError log('[refresh] opencode main oauth refresh check', { @@ -751,7 +903,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { nextStorage.refresh.mainRefreshLeaseTokenHash = refreshTokenHash }) - const latestLease = await loadAccounts() + const latestLease = await loadAccounts(accountStoragePath) log( '[refresh] opencode main oauth refresh lease acquired', { @@ -897,7 +1049,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const run = async () => { try { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) if (!mainRefreshEnabled(storage)) return const latestAuth = await getAuth() if (latestAuth.type !== 'oauth') return @@ -1143,7 +1295,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { isCache1hEnabled() && getCache1hMode() === 'hybrid' ) { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const tracked = await cacheKeepManager.track({ sessionId: relayAffinity, url: rewritten.url?.toString() ?? rewritten.input.toString(), @@ -1187,54 +1339,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return response } - async function refreshMainQuotaCache( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - const quota = await fetchOAuthQuotaSnapshot({ accessToken }) - mainQuotaCache = { - accessToken, - refreshAfter: getQuotaNextRefreshAt(quota, storage, now), - quota, - } - return quota - } - - function refreshMainQuotaCacheInBackground( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaRefreshPromise || now < mainQuotaRetryAfter) return - mainQuotaRefreshPromise = refreshMainQuotaCache( - accessToken, - storage, - ) - .catch((error) => { - mainQuotaRetryAfter = now + getQuotaCheckIntervalMs(storage) - throw error - }) - .finally(() => { - mainQuotaRefreshPromise = null - }) - void mainQuotaRefreshPromise.catch(() => {}) - } - - async function getMainQuotaForRouting( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaCache?.accessToken !== accessToken) { - return await refreshMainQuotaCache(accessToken, storage) - } - if (now >= mainQuotaCache.refreshAfter) { - refreshMainQuotaCacheInBackground(accessToken, storage) - } - return mainQuotaCache.quota - } - async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -1296,7 +1400,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!isReplayableRequest(input, init?.body)) return mainResponse const loadStart = nowMs() - const storage = existingStorage ?? (await loadAccounts()) + const storage = existingStorage ?? (await loadAccounts(accountStoragePath)) trace?.mark('fallback_load_storage', { ms: roundMs(nowMs() - loadStart), cached: !!existingStorage, @@ -1385,8 +1489,31 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { throw new Error('OAuth access token is missing after refresh') } const loadStart = nowMs() - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) + quotaManager.updateStorage(storage) + quotaManager.seedFallbacksFromAccounts(storage?.accounts ?? []) trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) + + /** Show quota toast from current QuotaManager state */ + function showQuotaToastFromCache() { + const mainEntry = quotaManager.getMain() + if (!mainEntry) return + const fallbacks = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false, + ) + const mainPassesPolicy = quotaSnapshotPassesPolicy( + mainEntry.quota, + storage, + ) + let activeId: string | undefined + if (mainPassesPolicy) { + activeId = 'main' + } else { + activeId = fallbacks[0]?.id + } + showQuotaToast(mainEntry.quota, fallbacks, activeId) + } + let preselectedFallbackAccounts: | Awaited< ReturnType< @@ -1400,18 +1527,23 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) { try { const quotaStart = nowMs() - const mainQuota = await getMainQuotaForRouting( - auth.access, - storage, - ) + // Use QuotaManager: get cached or eagerly refresh if first time + let routingQuota = quotaManager.getMain()?.quota + if (!routingQuota) { + routingQuota = await quotaManager.refreshMain(auth.access) + showQuotaToastFromCache() + } else if (quotaManager.isMainStale()) { + // Background refresh — return stale to avoid blocking + void quotaManager.refreshMain(auth.access).catch(() => {}) + } trace.mark('main_quota_for_routing', { ms: roundMs(nowMs() - quotaStart), - passes: quotaSnapshotPassesPolicy(mainQuota, storage), + passes: quotaSnapshotPassesPolicy(routingQuota, storage), }) - if (!quotaSnapshotPassesPolicy(mainQuota, storage)) { + if (!quotaSnapshotPassesPolicy(routingQuota, storage)) { const fallbackStart = nowMs() preselectedFallbackAccounts = - await fallbackManager.getUsableFallbackAccounts() + await fallbackManager.getUsableFallbackAccounts(storage) trace.mark('preselect_fallback_accounts', { ms: roundMs(nowMs() - fallbackStart), accounts: preselectedFallbackAccounts.length, @@ -1439,6 +1571,46 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Main quota checks should optimize routing, not break requests. } } + + // Fail-closed: if failClosedOnUnknownQuota is set, quota API is backed off, + // and we have no cached quota, block the request. + const mainQuota = quotaManager.getMain()?.quota + if ( + storage?.quota?.failClosedOnUnknownQuota && + !mainQuota && + quotaManager.isBackedOff() + ) { + const lastError = quotaManager.getLastApiError() + const msg = lastError + ? formatQuotaBackoffMessage(lastError, Date.now()) + : 'Quota API unavailable' + log('[killswitch] blocked: quota API backed off', { + nextRetryAt: lastError?.nextRetryAt, + retryCount: lastError?.retryCount, + }) + return new Response( + JSON.stringify({ + type: 'error', + error: { type: 'rate_limit_error', message: msg }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String( + lastError?.nextRetryAt + ? Math.max( + 1, + Math.ceil( + (lastError.nextRetryAt - Date.now()) / 1000, + ), + ) + : 60, + ), + }, + }, + ) + } const mainResponse = await sendWithAccessToken( input, init, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index be2a3de..920bf13 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -263,6 +263,18 @@ describe('auth.loader', () => { let capturedBody: string | undefined globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -331,7 +343,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined let capturedHeaders: Headers | undefined globalThis.fetch = mock((input: any, init: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url capturedBody = init?.body capturedHeaders = new Headers(init?.headers) return Promise.resolve( @@ -790,7 +814,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -832,7 +868,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -869,7 +917,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined const mockClient = createMockClient() - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -924,7 +984,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) capturedHeaders = new Headers(init?.headers) return Promise.resolve(new Response(null, { status: 200 })) @@ -974,7 +1046,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) return Promise.resolve(new Response(null, { status: 200 })) }, @@ -1653,7 +1737,19 @@ describe('auth.loader', () => { let capturedUrl: string | undefined globalThis.fetch = mock((input: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -1875,6 +1971,8 @@ describe('auth.loader', () => { ]) expect(second).toBe('message-2') + // Background quota refresh involves file-lock I/O; wait for it to fire. + await new Promise((r) => setTimeout(r, 50)) expect(quotaCalls).toBe(2) expect(messageCalls).toBe(2) } finally { diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts new file mode 100644 index 0000000..0718cec --- /dev/null +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { QuotaManager } from '@cortexkit/anthropic-auth-core' + +function makeQuotaResponse(now: number) { + return new Response( + JSON.stringify({ + five_hour: { + utilization: 25, + resets_at: new Date(now + 3600_000).toISOString(), + }, + seven_day: { + utilization: 50, + }, + }), + { status: 200 }, + ) +} + +describe('QuotaManager', () => { + let now: number + + beforeEach(() => { + now = 1_000_000 + }) + + function createQM(fetchImpl?: typeof fetch) { + return new QuotaManager({ + storage: null, + fetchImpl, + now: () => now, + }) + } + + describe('backoff', () => { + test('first 429 backs off for 60s', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + + expect(qm.isBackedOff()).toBe(true) + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('repeated 429s escalate backoff exponentially', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // First failure: 60s + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + expect(qm.isBackedOff()).toBe(false) + + // Second failure: 120s + try { + await qm.refreshMain('token') + } catch {} + now += 119_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('backoff caps at 15 minutes', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // Trigger 8 failures to exceed cap + for (let i = 0; i < 8; i++) { + try { + await qm.refreshMain('token') + } catch {} + now += 16 * 60_000 + } + + try { + await qm.refreshMain('token') + } catch {} + now += 14 * 60_000 + expect(qm.isBackedOff()).toBe(true) + now += 2 * 60_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('successful fetch resets backoff', async () => { + let failNext = true + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + failNext = false + await qm.refreshMain('token') + expect(qm.isBackedOff()).toBe(false) + + // Next failure starts from 60s again (not escalated) + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('getLastApiError exposes backoff state', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + expect(qm.getLastApiError()).toBeUndefined() + + try { + await qm.refreshMain('token') + } catch {} + + const err = qm.getLastApiError() + expect(err).toBeDefined() + expect(err!.retryCount).toBe(1) + expect(err!.nextRetryAt).toBeGreaterThan(now) + }) + + test('500 errors also trigger backoff', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('internal error', { status: 500 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + }) + + test('returns cached quota during backoff', async () => { + let failNext = false + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + const first = await qm.refreshMain('token') + expect(first).toBeDefined() + + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + + const cached = qm.getMain() + expect(cached).not.toBeNull() + }) + }) + + describe('persistence', () => { + test('seeds main quota from persisted storage', () => { + const quota = { + quotas: [], + expires: new Date(2_000_000).toISOString(), + } + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainQuota: quota as any, + mainQuotaCheckedAt: 900_000, + }, + }, + now: () => 1_000_000, + }) + + const main = qm.getMain() + expect(main).not.toBeNull() + expect(main!.checkedAt).toBe(900_000) + }) + + test('calls onMainQuotaFetched after successful fetch', async () => { + let callbackQuota: any = null + const fetchMock = mock(() => + Promise.resolve(makeQuotaResponse(now)), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onMainQuotaFetched: (quota, checkedAt) => { + callbackQuota = { quota, checkedAt } + }, + }) + + await qm.refreshMain('token') + expect(callbackQuota).not.toBeNull() + expect(callbackQuota.checkedAt).toBe(now) + }) + + test('seeds backoff state from persisted storage', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: now - 30_000, + nextRetryAt: now + 30_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(true) + }) + + test('ignores expired persisted backoff', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'old error', + checkedAt: now - 120_000, + nextRetryAt: now - 60_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(false) + }) + + test('calls onApiError callback on failure', async () => { + let errorCallback: any = null + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onApiError: (error) => { + errorCallback = error + }, + }) + + try { + await qm.refreshMain('token') + } catch {} + + expect(errorCallback).not.toBeNull() + expect(errorCallback.retryCount).toBe(1) + expect(errorCallback.nextRetryAt).toBeGreaterThan(now) + }) + }) +})