* 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:
parent
c51d955f0c
commit
a4583eb765
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue