fix: add retry tolerance to frontend polling (H3)

A single transient network error (502, timeout) no longer kills the
polling loop. The frontend now tolerates up to 3 consecutive errors
before abandoning. Successful fetches reset the counter.

Also aligns frontend polling timeout (15 min) with backend timeout.

Ref #57 (H3, M5)
This commit is contained in:
Pier-Jean Malandrino 2026-04-07 14:41:00 +02:00
parent f58b563a13
commit 4b1a15f49a
2 changed files with 45 additions and 6 deletions

View file

@ -154,17 +154,49 @@ describe('useAnalysisStore', () => {
expect(store.currentAnalysis.status).toBe('FAILED')
})
it('polling stops on fetch error', async () => {
it('polling retries on transient errors and stops after MAX_POLL_RETRIES', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockRejectedValue(new Error('network'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useAnalysisStore()
await store.run('d1')
// First two errors: still polling
await vi.advanceTimersByTimeAsync(2000)
expect(store.running).toBe(true)
await vi.advanceTimersByTimeAsync(2000)
expect(store.running).toBe(true)
// Third error: stops polling
await vi.advanceTimersByTimeAsync(2000)
expect(store.running).toBe(false)
})
it('polling resets error count on successful fetch', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
vi.spyOn(console, 'warn').mockImplementation(() => {})
// Fail once, succeed, fail once — should NOT stop polling
api.fetchAnalysis
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({ ...job, status: 'RUNNING' })
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({ ...job, status: 'COMPLETED' })
const store = useAnalysisStore()
await store.run('d1')
await vi.advanceTimersByTimeAsync(2000) // error 1
expect(store.running).toBe(true)
await vi.advanceTimersByTimeAsync(2000) // success — resets counter
expect(store.running).toBe(true)
await vi.advanceTimersByTimeAsync(2000) // error 1 again
expect(store.running).toBe(true)
await vi.advanceTimersByTimeAsync(2000) // success — COMPLETED
expect(store.running).toBe(false)
})
})

View file

@ -10,7 +10,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
const error = ref<string | null>(null)
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
const pollingTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
const MAX_POLLING_DURATION = 15 * 60 * 1000 // 15 minutes — aligned with backend timeout
const MAX_POLL_RETRIES = 3
const currentPages = computed<Page[]>(() => {
if (!currentAnalysis.value?.pagesJson) return []
@ -87,9 +88,11 @@ export const useAnalysisStore = defineStore('analysis', () => {
function startPolling(id: string): void {
stopPolling()
let consecutiveErrors = 0
pollingInterval.value = setInterval(async () => {
try {
const updated = await api.fetchAnalysis(id)
consecutiveErrors = 0
currentAnalysis.value = updated
const idx = analyses.value.findIndex((a) => a.id === id)
if (idx !== -1) analyses.value[idx] = updated
@ -98,10 +101,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false
}
} catch (e) {
error.value = (e as Error).message || 'Polling error'
console.error('Polling error', e)
stopPolling()
running.value = false
consecutiveErrors++
console.warn(`Polling error (${consecutiveErrors}/${MAX_POLL_RETRIES})`, e)
if (consecutiveErrors >= MAX_POLL_RETRIES) {
error.value = (e as Error).message || 'Polling error'
console.error('Polling abandoned after retries', e)
stopPolling()
running.value = false
}
}
}, 2000)
pollingTimeout.value = setTimeout(() => {