diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 166442ee0..261a94f36 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -29,14 +29,14 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) -vi.mock('./opencode-go-usage-fetcher', () => ({ - fetchOpenCodeGoRateLimits: vi.fn() -})) - vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) +vi.mock('./opencode-go-usage-fetcher', () => ({ + fetchOpenCodeGoRateLimits: vi.fn() +})) + vi.mock('./minimax-fetcher', () => ({ fetchMiniMaxRateLimits: vi.fn() })) @@ -73,7 +73,7 @@ async function flushMicrotasks(times = 4): Promise { } function okProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], usedPercent: number, updatedAt = Date.now() ): ProviderRateLimits { @@ -93,7 +93,7 @@ function okProvider( } function errorProvider( - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok', + provider: ProviderRateLimits['provider'], message: string ): ProviderRateLimits { return { @@ -106,6 +106,20 @@ function errorProvider( } } +function unavailableProvider( + provider: ProviderRateLimits['provider'], + message = 'Not configured' +): ProviderRateLimits { + return { + provider, + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'unavailable' + } +} + function serviceInternals(service: RateLimitService): { fetchAll: () => Promise } { return service as unknown as { fetchAll: () => Promise } } @@ -344,6 +358,187 @@ describe('RateLimitService', () => { } }) + it('recovers a failed deferred-startup Claude fetch on the next focus', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'auth restarting')) + .mockResolvedValueOnce(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(service.getState().claude?.status).toBe('error') + expect(service.getState().codex?.status).toBe('ok') + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('throttles repeated active-window retries while Claude is still failing', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(30 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a full-fetch retry on the 5-minute cadence for a provider without a dedicated fetch cycle', async () => { + vi.useFakeTimers() + try { + // Kimi has no individual fetch cycle, so recovering it re-runs fetchAll + // (which hits Claude's tight-budget endpoint). A durable Kimi error must + // not drive that full fetch every 30s — it stays on the 5-minute cadence. + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(errorProvider('kimi', 'token expired')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().kimi?.status).toBe('error') + + // First activation recovers immediately (retry timestamps start at 0). + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // Well past the 30s failure throttle but inside the 5-minute window: the + // full fetch (and the Claude read it entails) must not fire again. + await vi.advanceTimersByTimeAsync(2 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + + // After the full 5-minute window the retry fires again. + await vi.advanceTimersByTimeAsync(4 * 60 * 1000) + window.emit('restore') + await vi.advanceTimersByTimeAsync(0) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(3) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('debounces unavailable providers on active window events', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(unavailableProvider('claude')) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(unavailableProvider('codex')) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(unavailableProvider('gemini')) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(unavailableProvider('opencode-go')) + vi.mocked(fetchKimiRateLimits).mockResolvedValue(unavailableProvider('kimi')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(1) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + window.emit('show') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) + expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(2) + expect(fetchOpenCodeGoRateLimits).toHaveBeenCalledTimes(2) + expect(fetchKimiRateLimits).toHaveBeenCalledTimes(2) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + + it('still debounces a focus event within the window after a successful fetch', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + // A focus within MIN_REFETCH_MS after a GOOD fetch must still no-op. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + it('keeps recent stale data across repeated failures', async () => { const service = new RateLimitService() const internal = serviceInternals(service) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index b3800b318..cd301cfe5 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -59,6 +59,15 @@ type MiniMaxResolvedConfig = { } type GeminiCliOAuthEnabledResolver = () => boolean +type ActiveRateLimitProvider = ProviderRateLimits['provider'] +type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } // Why: Claude's subscription usage endpoint has a tight request budget. Quota // state is informational, so prefer keeping a recent snapshot over polling it @@ -67,6 +76,15 @@ const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: these providers have a dedicated fetch cycle, so an activation retry can +// refresh just the failing one. Providers without one force a full fetchAll, so +// their error retries stay on the 5-minute cadence to protect Claude's budget. +const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 @@ -122,7 +140,18 @@ export class RateLimitService { private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null - private lastFetchAt = 0 + // Why: after the first recovery attempt, repeated focus/show/restore events + // during the same outage should not create a tight provider retry loop. + private lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } private mainWindow: BrowserWindow | null = null private detachWindowListeners: (() => void) | null = null private isFetching = false @@ -698,17 +727,104 @@ export class RateLimitService { return this.mainWindow.isFocused() } + private getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a newly added provider is compile-forced to have + // an active-refresh entry — a missing one silently never recovers from a + // startup error (antigravity was omitted once and needed a fix-up). + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data. Keep it eligible for + // activation recovery while throttling repeated events per provider. + if (limits.status === 'error') { + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? ACTIVE_FAILURE_REFETCH_MS + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + await this.fetchAll() + return + } + + // Why: a fetch already in flight will refresh these providers; skip without + // consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: partial failures of providers with a dedicated fetch cycle should + // recover without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + private async refreshIfWindowActive(): Promise { if (!this.shouldBackgroundPoll()) { return } - // Why: startup intentionally skips the pre-paint fetch. The first visible - // activation must still populate usage after update relaunches where the - // timer can be focus-gated for a long time. - if (Date.now() - this.lastFetchAt < MIN_REFETCH_MS) { - return - } - await this.fetchAll() + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) } private async fetchAll(options?: { force?: boolean }): Promise { @@ -1411,8 +1527,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { @@ -1465,8 +1579,6 @@ export class RateLimitService { ...this.state, codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex }) - - this.lastFetchAt = Date.now() } private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise { @@ -1524,8 +1636,6 @@ export class RateLimitService { ? this.applyStalePolicy(claude, previousState.claude) : this.state.claude }) - - this.lastFetchAt = Date.now() } private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { @@ -1563,8 +1673,6 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) - - this.lastFetchAt = Date.now() } private applyStalePolicy(