diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx index a5ba2d1..f93ce20 100644 --- a/web/src/api/useJob.test.tsx +++ b/web/src/api/useJob.test.tsx @@ -85,16 +85,54 @@ describe('useJob', () => { expect(fetchMock).toHaveBeenCalledTimes(2) }) - it('exposes a failed poll as the error and stops', async () => { - const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), errorResponse(404, 'not_found')) + it('exposes a failed poll as the error and keeps polling to the terminal status', async () => { + const fetchMock = stubFetch( + job('running', { dinners: ['Tacos'] }), + errorResponse(503, 'unavailable', true), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) const { result } = renderJob('j1') await advance(0) await advance(500) - expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) - expect(result.current.error).toMatchObject({ status: 404, code: 'not_found' }) - expect(vi.getTimerCount()).toBe(0) expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' }) + expect(vi.getTimerCount()).toBe(1) + + // The backoff carries on from the failed poll: the next one is 1 s later. + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops polling on unmount after a failed poll', async () => { + const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) + const { result, unmount } = renderJob('j1') + + await advance(0) + expect(result.current).toMatchObject({ status: undefined, result: null }) + expect(result.current.error).toMatchObject({ status: 503 }) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('stops polling the old job when the job id changes', async () => { + const fetchMock = stubFetch(job('running', null), job('running', null)) + const { result, rerender } = renderJob('j1') + + await advance(0) + rerender({ id: 'j2' }) + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + await advance(0) + expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') + expect(vi.getTimerCount()).toBe(1) }) it('stops polling on unmount', async () => { diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts index e0fdaeb..2a26c2c 100644 --- a/web/src/api/useJob.ts +++ b/web/src/api/useJob.ts @@ -18,7 +18,8 @@ export type JobState = { status: JobStatus | undefined // The latest result, partial while the job runs. result: R | null - // The job's failure, or the polling request's own failure. + // The job's failure, or the latest polling request's own failure (cleared + // by the next poll that answers). error: Error | null } @@ -33,8 +34,9 @@ function isTerminal(status: JobStatus): boolean { } // useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, -// backing off from 500 ms to 2 s. Polling stops at a terminal status, when a -// poll fails and on unmount. A null jobId polls nothing. +// backing off from 500 ms to 2 s. A failed poll is exposed as the error and +// polling continues. Polling stops only at a terminal status, when jobId +// changes and on unmount. A null jobId polls nothing. export function useJob(jobId: string | null): JobState { const { apiFetch } = useApi() const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) @@ -45,6 +47,11 @@ export function useJob(jobId: string | null): JobState { let timer: ReturnType | undefined let delay = FIRST_DELAY_MS + function next() { + timer = setTimeout(poll, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + async function poll() { try { const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) @@ -52,15 +59,16 @@ export function useJob(jobId: string | null): JobState { const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null setState({ jobId, job: { status: job.status, result: job.result, error } }) if (isTerminal(job.status)) return - timer = setTimeout(poll, delay) - delay = Math.min(delay * 2, MAX_DELAY_MS) } catch (error) { if (!active) return + // A failed poll is not the job's end: keep the last known status and + // result, expose the request's error, and poll again. setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, })) } + next() } void poll()