fix(rate-limits): debounce auto-refresh off last successful fetch

A failed post-update/relaunch fetch stamped the single debounce
timestamp the same as a success, so refreshIfWindowActive() no-oped
every focus/show/restore and the deferred-startup retry for the next
5 minutes — leaving the status bar stuck on "Refresh failed" until the
15-minute background poll (which on Windows also requires the relaunched
window to be focused).

Key the debounce off the last SUCCESSFUL read instead: only stamp
lastSuccessfulFetchAt when a provider returned fresh 'ok' data, so a
failed fetch no longer suppresses auto-recovery on the next window
activation. Removes the now-unused lastFetchAt field.

Fixes #5355

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-27 23:09:15 -07:00
co-authored by Orca
parent b45cb545c5
commit ce74cecab3
2 changed files with 90 additions and 5 deletions
+65
View File
@@ -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)
+25 -5
View File
@@ -95,7 +95,9 @@ export class RateLimitService {
private pollInterval: number = DEFAULT_POLL_MS
private timer: ReturnType<typeof setInterval> | null = null
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | 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<void> {
@@ -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<void> {
@@ -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(