mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user