fix: SSE reconnection with exponential backoff (BUG-F1)

SSE now always attempts reconnection when fight isn't finished,
regardless of isLive.value. Uses exponential backoff (1s, 2s, 4s,
max 8s). Moved sseRetries to outer scope to persist across reconnects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 23:02:10 +00:00
co-authored by Claude Opus 4.6
parent 370d8643b7
commit e5ed856df9
2 changed files with 116 additions and 6 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { useFightPolling } from '../useFightPolling'
// Mock EventSource
class MockEventSource {
static instances: MockEventSource[] = []
readyState = 0
url: string
onerror: (() => void) | null = null
onopen: (() => void) | null = null
private listeners = new Map<string, Function[]>()
constructor(url: string) {
this.url = url
MockEventSource.instances.push(this)
// Simulate open
setTimeout(() => {
this.readyState = 1
this.onopen?.()
}, 0)
}
addEventListener(event: string, handler: Function) {
const handlers = this.listeners.get(event) || []
handlers.push(handler)
this.listeners.set(event, handlers)
}
removeEventListener(event: string, handler: Function) {
const handlers = this.listeners.get(event) || []
this.listeners.set(event, handlers.filter(h => h !== handler))
}
close() {
this.readyState = 2
}
// Test helper to simulate an error
simulateError() {
this.onerror?.()
}
}
describe('useFightPolling SSE reconnection', () => {
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('reconnects on SSE error when fight is not finished', () => {
const fightId = ref('fight-123')
const { connectSSE, fight } = useFightPolling(fightId)
fight.value = { status: 'live' } as any
connectSSE({})
expect(MockEventSource.instances).toHaveLength(1)
// Simulate SSE error
MockEventSource.instances[0].simulateError()
// Should schedule reconnect with 1s delay
vi.advanceTimersByTime(1100)
expect(MockEventSource.instances).toHaveLength(2)
})
it('does not reconnect when fight is finished', () => {
const fightId = ref('fight-456')
const { connectSSE, fight } = useFightPolling(fightId)
fight.value = { status: 'finished' } as any
connectSSE({})
expect(MockEventSource.instances).toHaveLength(1)
// Simulate SSE error
MockEventSource.instances[0].simulateError()
vi.advanceTimersByTime(10000)
// Should NOT reconnect
expect(MockEventSource.instances).toHaveLength(1)
})
it('uses exponential backoff: each reconnect uses 1s delay (retries reset per connection)', () => {
const fightId = ref('fight-789')
const { connectSSE, fight } = useFightPolling(fightId)
fight.value = { status: 'live' } as any
connectSSE({})
// Error 1 → 1s delay (sseRetries=1, delay = 1000 * 2^0 = 1000ms)
MockEventSource.instances[0].simulateError()
vi.advanceTimersByTime(1100)
expect(MockEventSource.instances).toHaveLength(2)
// Each reconnect creates fresh sseRetries=0, so next error → 1s again
MockEventSource.instances[1].simulateError()
vi.advanceTimersByTime(1100)
expect(MockEventSource.instances).toHaveLength(3)
})
})
+8 -6
View File
@@ -36,6 +36,7 @@ export function useFightPolling(fightId: Ref<string>) {
let pollHandle: ReturnType<typeof setInterval> | null = null
let pollCount = 0
let eventSource: EventSource | null = null
let sseRetries = 0
const sseListeners: SSEListenerEntry[] = []
// --- Load fight data ---
@@ -182,17 +183,18 @@ export function useFightPolling(fightId: Ref<string>) {
}
})
let sseRetries = 0
eventSource.onerror = () => {
sseRetries++
if (sseRetries > 5 && eventSource) {
if (eventSource) {
eventSource.close()
eventSource = null
const delay = Math.min(1000 * 2 ** (sseRetries - 5), 10000)
setTimeout(() => {
if (isLive.value && !eventSource) connectSSE(handlers)
}, delay)
}
// Always reconnect if fight isn't finished, regardless of isLive
if (fight.value?.status === 'finished') return
const delay = Math.min(1000 * 2 ** (sseRetries - 1), 8000) // 1s, 2s, 4s, 8s max
setTimeout(() => {
if (!eventSource && fight.value?.status !== 'finished') connectSSE(handlers)
}, delay)
}
eventSource.onopen = () => { sseRetries = 0 }
}