From 4b1a15f49a1b20713529c4c6318610f489821b0e Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Tue, 7 Apr 2026 14:41:00 +0200 Subject: [PATCH] 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) --- frontend/src/features/analysis/store.test.ts | 34 +++++++++++++++++++- frontend/src/features/analysis/store.ts | 17 +++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/analysis/store.test.ts b/frontend/src/features/analysis/store.test.ts index 6c6fb15..4806d00 100644 --- a/frontend/src/features/analysis/store.test.ts +++ b/frontend/src/features/analysis/store.test.ts @@ -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) }) }) diff --git a/frontend/src/features/analysis/store.ts b/frontend/src/features/analysis/store.ts index 8891cb7..6c509a6 100644 --- a/frontend/src/features/analysis/store.ts +++ b/frontend/src/features/analysis/store.ts @@ -10,7 +10,8 @@ export const useAnalysisStore = defineStore('analysis', () => { const error = ref(null) const pollingInterval = ref | null>(null) const pollingTimeout = ref | 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(() => { 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(() => {