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:
co-authored by
Claude Opus 4.6
parent
370d8643b7
commit
e5ed856df9
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user