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) 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 currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingSSEEvents = ref<{ type: string; data: any }[]>([]) 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 pollCount = 0
let pollErrors = 0
let eventSource: EventSource | null = null let eventSource: EventSource | null = null
let sseRetries = 0 let sseRetries = 0
const sseListeners: SSEListenerEntry[] = [] 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) keepLive?: boolean // Don't set isLive=false (human fights manage lifecycle via SSE)
}) { }) {
pollCount = 0 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++ pollCount++
const s = await loadFight() const s = await loadFight()
if (s === null) {
// Error or rate-limited
pollErrors++
} else {
pollErrors = 0 // Reset on success
}
if (s === 'finished') { if (s === 'finished') {
if (!eventSource) { if (!eventSource) {
if (!callbacks?.keepLive) isLive.value = false if (!callbacks?.keepLive) isLive.value = false
disconnectSSE() disconnectSSE()
stopPolling() stopPolling()
callbacks?.onFinished?.() callbacks?.onFinished?.()
return
} }
} else if (pollCount > 120) { } else if (pollCount > 120) {
isLive.value = false isLive.value = false
fightError.value = 'Fight took too long. Try refreshing.' fightError.value = 'Fight took too long. Try refreshing.'
stopPolling() stopPolling()
callbacks?.onTimeout?.() callbacks?.onTimeout?.()
return
} }
}, 1500)
schedulePoll()
}
schedulePoll()
} }
function stopPolling() { function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null } if (pollHandle) { clearTimeout(pollHandle); pollHandle = null }
} }
// --- SSE --- // --- SSE ---