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 pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
let pollCount = 0
|
let pollCount = 0
|
||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
|
let sseRetries = 0
|
||||||
const sseListeners: SSEListenerEntry[] = []
|
const sseListeners: SSEListenerEntry[] = []
|
||||||
|
|
||||||
// --- Load fight data ---
|
// --- Load fight data ---
|
||||||
@@ -182,17 +183,18 @@ export function useFightPolling(fightId: Ref<string>) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let sseRetries = 0
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
sseRetries++
|
sseRetries++
|
||||||
if (sseRetries > 5 && eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
eventSource = null
|
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 }
|
eventSource.onopen = () => { sseRetries = 0 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user