Keep polling a job after a failed poll
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m31s
CI / ci (pull_request) Successful in 1m31s

A failed GET /api/v1/jobs/{id} no longer ends polling. useJob exposes the
request's error, keeps the last known status and result, and schedules the
next poll on the same 500 ms to 2 s backoff. Polling stops only at a
terminal status, a changed job id or unmount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:37:11 -04:00
co-authored by Claude Opus 5.5
parent 913c1e5337
commit 690e1056a6
2 changed files with 56 additions and 10 deletions
+43 -5
View File
@@ -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 () => {
+13 -5
View File
@@ -18,7 +18,8 @@ export type JobState<R> = {
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<R>(jobId: string | null): JobState<R> {
const { apiFetch } = useApi()
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
@@ -45,6 +47,11 @@ export function useJob<R>(jobId: string | null): JobState<R> {
let timer: ReturnType<typeof setTimeout> | 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<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
@@ -52,15 +59,16 @@ export function useJob<R>(jobId: string | null): JobState<R> {
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()