fix: polling backoff escalates on errors — 1.5s → 3s → 6s → 8s max

Changed from fixed 1.5s setInterval to recursive setTimeout with
exponential backoff on consecutive errors. Resets to 1.5s on success.
Added 2 tests verifying backoff escalation and reset behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 05:18:00 +00:00
co-authored by Claude Opus 4.6
parent 5bc8932d25
commit 642da1e477
2 changed files with 112 additions and 4 deletions
@@ -106,3 +106,86 @@ describe('useFightPolling SSE reconnection', () => {
expect(MockEventSource.instances).toHaveLength(3)
})
})
describe('useFightPolling exponential backoff', () => {
beforeEach(() => {
vi.useFakeTimers()
MockEventSource.instances = []
// @ts-expect-error — mock global
globalThis.EventSource = MockEventSource
})
afterEach(() => {
vi.useRealTimers()
// @ts-expect-error — cleanup mock
delete globalThis.EventSource
})
it('polling uses exponential backoff on consecutive errors', async () => {
let fetchCallCount = 0
const originalFetch = globalThis.fetch
globalThis.fetch = vi.fn().mockImplementation(() => {
fetchCallCount++
return Promise.reject(new Error('network error'))
})
const fightId = ref('fight-backoff')
const { startPolling, stopPolling } = useFightPolling(fightId)
startPolling()
// First poll at 1500ms
await vi.advanceTimersByTimeAsync(1600)
expect(fetchCallCount).toBe(1)
// After error, next poll at 3000ms (1500 * 2^1)
await vi.advanceTimersByTimeAsync(3100)
expect(fetchCallCount).toBe(2)
// After 2 errors, next poll at 6000ms (1500 * 2^2)
await vi.advanceTimersByTimeAsync(6100)
expect(fetchCallCount).toBe(3)
stopPolling()
globalThis.fetch = originalFetch
})
it('polling resets backoff on success', async () => {
let fetchCallCount = 0
const originalFetch = globalThis.fetch
globalThis.fetch = vi.fn().mockImplementation(() => {
fetchCallCount++
if (fetchCallCount <= 2) {
return Promise.reject(new Error('network error'))
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'live', rounds: [] }),
})
})
const fightId = ref('fight-reset')
const { startPolling, stopPolling } = useFightPolling(fightId)
startPolling()
// First poll at 1500ms → error
await vi.advanceTimersByTimeAsync(1600)
expect(fetchCallCount).toBe(1)
// Next at 3000ms → error
await vi.advanceTimersByTimeAsync(3100)
expect(fetchCallCount).toBe(2)
// Next at 6000ms → success (resets backoff)
await vi.advanceTimersByTimeAsync(6100)
expect(fetchCallCount).toBe(3)
// After success, back to 1500ms
await vi.advanceTimersByTimeAsync(1600)
expect(fetchCallCount).toBe(4)
stopPolling()
globalThis.fetch = originalFetch
})
})
+29 -4
View File
@@ -33,8 +33,9 @@ export function useFightPolling(fightId: Ref<string>) {
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
let pollHandle: ReturnType<typeof setInterval> | null = null
let pollHandle: ReturnType<typeof setTimeout> | null = null
let pollCount = 0
let pollErrors = 0
let eventSource: EventSource | null = null
let sseRetries = 0
const sseListeners: SSEListenerEntry[] = []
@@ -69,27 +70,51 @@ export function useFightPolling(fightId: Ref<string>) {
keepLive?: boolean // Don't set isLive=false (human fights manage lifecycle via SSE)
}) {
pollCount = 0
pollHandle = setInterval(async () => {
pollErrors = 0
function schedulePoll() {
// Exponential backoff on consecutive errors: 1.5s → 3s → 6s → max 8s
const delay = pollErrors > 0
? Math.min(1500 * 2 ** pollErrors, 8000)
: 1500
pollHandle = setTimeout(poll, delay)
}
async function poll() {
pollCount++
const s = await loadFight()
if (s === null) {
// Error or rate-limited
pollErrors++
} else {
pollErrors = 0 // Reset on success
}
if (s === 'finished') {
if (!eventSource) {
if (!callbacks?.keepLive) isLive.value = false
disconnectSSE()
stopPolling()
callbacks?.onFinished?.()
return
}
} else if (pollCount > 120) {
isLive.value = false
fightError.value = 'Fight took too long. Try refreshing.'
stopPolling()
callbacks?.onTimeout?.()
return
}
}, 1500)
schedulePoll()
}
schedulePoll()
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
if (pollHandle) { clearTimeout(pollHandle); pollHandle = null }
}
// --- SSE ---