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:
parent
f58b563a13
commit
4b1a15f49a
2 changed files with 45 additions and 6 deletions
|
|
@ -154,17 +154,49 @@ describe('useAnalysisStore', () => {
|
||||||
expect(store.currentAnalysis.status).toBe('FAILED')
|
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' }
|
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
|
||||||
api.createAnalysis.mockResolvedValue(job)
|
api.createAnalysis.mockResolvedValue(job)
|
||||||
api.fetchAnalysis.mockRejectedValue(new Error('network'))
|
api.fetchAnalysis.mockRejectedValue(new Error('network'))
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
const store = useAnalysisStore()
|
const store = useAnalysisStore()
|
||||||
await store.run('d1')
|
await store.run('d1')
|
||||||
|
|
||||||
|
// First two errors: still polling
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
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)
|
expect(store.running).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
||||||
const pollingTimeout = ref<ReturnType<typeof setTimeout> | 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[]>(() => {
|
const currentPages = computed<Page[]>(() => {
|
||||||
if (!currentAnalysis.value?.pagesJson) return []
|
if (!currentAnalysis.value?.pagesJson) return []
|
||||||
|
|
@ -87,9 +88,11 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
|
|
||||||
function startPolling(id: string): void {
|
function startPolling(id: string): void {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
let consecutiveErrors = 0
|
||||||
pollingInterval.value = setInterval(async () => {
|
pollingInterval.value = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const updated = await api.fetchAnalysis(id)
|
const updated = await api.fetchAnalysis(id)
|
||||||
|
consecutiveErrors = 0
|
||||||
currentAnalysis.value = updated
|
currentAnalysis.value = updated
|
||||||
const idx = analyses.value.findIndex((a) => a.id === id)
|
const idx = analyses.value.findIndex((a) => a.id === id)
|
||||||
if (idx !== -1) analyses.value[idx] = updated
|
if (idx !== -1) analyses.value[idx] = updated
|
||||||
|
|
@ -98,10 +101,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
running.value = false
|
running.value = false
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message || 'Polling error'
|
consecutiveErrors++
|
||||||
console.error('Polling error', e)
|
console.warn(`Polling error (${consecutiveErrors}/${MAX_POLL_RETRIES})`, e)
|
||||||
stopPolling()
|
if (consecutiveErrors >= MAX_POLL_RETRIES) {
|
||||||
running.value = false
|
error.value = (e as Error).message || 'Polling error'
|
||||||
|
console.error('Polling abandoned after retries', e)
|
||||||
|
stopPolling()
|
||||||
|
running.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
pollingTimeout.value = setTimeout(() => {
|
pollingTimeout.value = setTimeout(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue