Keep polling a job after a failed poll
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:
@@ -85,16 +85,54 @@ describe('useJob', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes a failed poll as the error and stops', async () => {
|
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
||||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), errorResponse(404, 'not_found'))
|
const fetchMock = stubFetch(
|
||||||
|
job('running', { dinners: ['Tacos'] }),
|
||||||
|
errorResponse(503, 'unavailable', true),
|
||||||
|
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||||
|
)
|
||||||
const { result } = renderJob('j1')
|
const { result } = renderJob('j1')
|
||||||
|
|
||||||
await advance(0)
|
await advance(0)
|
||||||
await advance(500)
|
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(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 () => {
|
it('stops polling on unmount', async () => {
|
||||||
|
|||||||
+13
-5
@@ -18,7 +18,8 @@ export type JobState<R> = {
|
|||||||
status: JobStatus | undefined
|
status: JobStatus | undefined
|
||||||
// The latest result, partial while the job runs.
|
// The latest result, partial while the job runs.
|
||||||
result: R | null
|
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
|
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,
|
// 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
|
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||||
// poll fails and on unmount. A null jobId polls nothing.
|
// 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> {
|
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
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 timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let delay = FIRST_DELAY_MS
|
let delay = FIRST_DELAY_MS
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
timer = setTimeout(poll, delay)
|
||||||
|
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
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
|
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 } })
|
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||||
if (isTerminal(job.status)) return
|
if (isTerminal(job.status)) return
|
||||||
timer = setTimeout(poll, delay)
|
|
||||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return
|
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) => ({
|
setState((prev) => ({
|
||||||
jobId,
|
jobId,
|
||||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
void poll()
|
void poll()
|
||||||
|
|||||||
Reference in New Issue
Block a user