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:
co-authored by
Claude Opus 4.6
parent
5bc8932d25
commit
642da1e477
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
Reference in New Issue
Block a user