fix(rate-limits): auto-refresh stuck on "Refresh failed" after update relaunch (#5355) (#6560)

* 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>

* fix(rate-limits): narrow active refresh retries

* fix(rate-limits): refresh after live Claude credential handoff

* fix(rate-limits): cover antigravity provider in active refresh plan

Co-authored-by: Orca <help@stably.ai>

* refactor(rate-limits): compile-enforce active-refresh provider coverage

Co-authored-by: Orca <help@stably.ai>

* fix(rate-limits): keep durable non-Claude/Codex error retries on the 5-minute full-fetch cadence

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-11 03:59:58 -07:00 committed by GitHub
parent c51d955f0c
commit a4583eb765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 325 additions and 22 deletions

View File

@ -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<void> {
}
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<void> } {
return service as unknown as { fetchAll: () => Promise<void> }
}
@ -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)

View File

@ -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<ActiveRateLimitProvider> = 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<typeof setInterval> | null = null
private deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | 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<ActiveRateLimitProvider, number> = {
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<ActiveRateLimitProvider, ProviderRateLimits | null> = {
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<void> {
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<void> {
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<void> {
@ -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<void> {
@ -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<void> {
@ -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<void> {
@ -1563,8 +1673,6 @@ export class RateLimitService {
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
})
this.lastFetchAt = Date.now()
}
private applyStalePolicy(