diff --git a/frontend/src/composables/__tests__/useFightPolling.test.ts b/frontend/src/composables/__tests__/useFightPolling.test.ts index 8d31ddf..cb2310b 100644 --- a/frontend/src/composables/__tests__/useFightPolling.test.ts +++ b/frontend/src/composables/__tests__/useFightPolling.test.ts @@ -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 + }) +}) diff --git a/frontend/src/composables/useFightPolling.ts b/frontend/src/composables/useFightPolling.ts index 397e9a9..a9b0ed6 100644 --- a/frontend/src/composables/useFightPolling.ts +++ b/frontend/src/composables/useFightPolling.ts @@ -33,8 +33,9 @@ export function useFightPolling(fightId: Ref) { const currentChallengeInfo = ref<{ type: string; label: string } | null>(null) const pendingSSEEvents = ref<{ type: string; data: any }[]>([]) - let pollHandle: ReturnType | null = null + let pollHandle: ReturnType | 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) { 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 ---