diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index 76bcc8672d6..9b9b60cbadf 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -247,6 +247,71 @@ describe('RateLimitService', () => { } }) + it('re-attempts on focus within the debounce window after a failed deferred-startup fetch', async () => { + vi.useFakeTimers() + try { + // Why: post-update warm relaunch — the first fetch fails (Claude CLI/auth + // still restarting), then succeeds once the runtime is ready. + vi.mocked(fetchClaudeRateLimits) + .mockResolvedValueOnce(errorProvider('claude', 'auth restarting')) + .mockResolvedValueOnce(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(errorProvider('codex', 'auth restarting')) + .mockResolvedValueOnce(okProvider('codex', 24)) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(errorProvider('gemini', 'auth restarting')) + vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue( + errorProvider('opencode-go', 'auth restarting') + ) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().claude?.status).toBe('error') + + // Focus well within MIN_REFETCH_MS — must NOT be suppressed because the + // first fetch produced no successful read. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2) + expect(service.getState().claude?.status).toBe('ok') + + 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 822dfa37101..8407b90909b 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -95,7 +95,9 @@ export class RateLimitService { private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null - private lastFetchAt = 0 + // Why: auto-refresh debounce keys off the last SUCCESSFUL read, not the last + // attempt, so a failed post-update/relaunch fetch doesn't suppress recovery. + private lastSuccessfulFetchAt = 0 private mainWindow: BrowserWindow | null = null private detachWindowListeners: (() => void) | null = null private isFetching = false @@ -616,7 +618,10 @@ export class RateLimitService { // 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) { + // Why: debounce off the last successful read so a failed post-update/relaunch + // fetch doesn't suppress auto-recovery for MIN_REFETCH_MS — focus/show/restore + // and the deferred-startup retry can re-attempt immediately after a failure. + if (Date.now() - this.lastSuccessfulFetchAt < MIN_REFETCH_MS) { return } await this.fetchAll() @@ -993,7 +998,16 @@ export class RateLimitService { kimi: this.applyStalePolicy(kimi, previousState.kimi) }) - this.lastFetchAt = Date.now() + // Why: re-arm the debounce only when a provider actually returned fresh + // quota data. An unconfigured provider reports 'unavailable' (not an error), + // so a relaunch where the only configured provider errored out must not + // count as success, otherwise focus/show/restore recovery stays suppressed. + const cycleHadFreshData = [claude, codex, gemini, opencodeGo, kimi].some( + (result) => result.status === 'ok' + ) + if (cycleHadFreshData) { + this.lastSuccessfulFetchAt = Date.now() + } } private async runFetchCodexOnlyCycle(): Promise { @@ -1039,7 +1053,10 @@ export class RateLimitService { codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex }) - this.lastFetchAt = Date.now() + // Why: a failed Codex read must not poison the success-debounce window. + if (codex.status === 'ok') { + this.lastSuccessfulFetchAt = Date.now() + } } private async runFetchClaudeOnlyCycle(): Promise { @@ -1082,7 +1099,10 @@ export class RateLimitService { : this.state.claude }) - this.lastFetchAt = Date.now() + // Why: a failed Claude read must not poison the success-debounce window. + if (claude.status === 'ok') { + this.lastSuccessfulFetchAt = Date.now() + } } private applyStalePolicy(