Skip to content

Commit 5e4c51f

Browse files
authored
Merge pull request #2239 from kahirokunn/retry-error
2 parents eb34fb3 + e2ff14e commit 5e4c51f

File tree

2 files changed

+178
-10
lines changed

2 files changed

+178
-10
lines changed

packages/toolkit/src/query/retry.ts

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { BaseQueryEnhancer } from './baseQueryTypes'
1+
import type {
2+
BaseQueryApi,
3+
BaseQueryArg,
4+
BaseQueryEnhancer,
5+
BaseQueryExtraOptions,
6+
BaseQueryFn,
7+
} from './baseQueryTypes'
8+
import { FetchBaseQueryError } from './fetchBaseQuery'
29
import { HandledError } from './HandledError'
310

411
/**
@@ -23,31 +30,73 @@ async function defaultBackoff(attempt: number = 0, maxRetries: number = 5) {
2330
)
2431
}
2532

26-
export interface RetryOptions {
27-
/**
28-
* How many times the query will be retried (default: 5)
29-
*/
30-
maxRetries?: number
33+
type RetryConditionFunction = (
34+
error: FetchBaseQueryError,
35+
args: BaseQueryArg<BaseQueryFn>,
36+
extraArgs: {
37+
attempt: number
38+
baseQueryApi: BaseQueryApi
39+
extraOptions: BaseQueryExtraOptions<BaseQueryFn> & RetryOptions
40+
}
41+
) => boolean
42+
43+
export type RetryOptions = {
3144
/**
3245
* Function used to determine delay between retries
3346
*/
3447
backoff?: (attempt: number, maxRetries: number) => Promise<void>
35-
}
48+
} & (
49+
| {
50+
/**
51+
* How many times the query will be retried (default: 5)
52+
*/
53+
maxRetries?: number
54+
retryCondition?: undefined
55+
}
56+
| {
57+
/**
58+
* Callback to determine if a retry should be attempted.
59+
* Return `true` for another retry and `false` to quit trying prematurely.
60+
*/
61+
retryCondition?: RetryConditionFunction
62+
maxRetries?: undefined
63+
}
64+
)
3665

3766
function fail(e: any): never {
3867
throw Object.assign(new HandledError({ error: e }), {
3968
throwImmediately: true,
4069
})
4170
}
4271

72+
const EMPTY_OPTIONS = {}
73+
4374
const retryWithBackoff: BaseQueryEnhancer<
4475
unknown,
4576
RetryOptions,
4677
RetryOptions | void
4778
> = (baseQuery, defaultOptions) => async (args, api, extraOptions) => {
48-
const options = {
49-
maxRetries: 5,
79+
// We need to figure out `maxRetries` before we define `defaultRetryCondition.
80+
// This is probably goofy, but ought to work.
81+
// Put our defaults in one array, filter out undefineds, grab the last value.
82+
const possibleMaxRetries: number[] = [
83+
5,
84+
((defaultOptions as any) || EMPTY_OPTIONS).maxRetries,
85+
((extraOptions as any) || EMPTY_OPTIONS).maxRetries,
86+
].filter(Boolean)
87+
const [maxRetries] = possibleMaxRetries.slice(-1)
88+
89+
const defaultRetryCondition: RetryConditionFunction = (_, __, { attempt }) =>
90+
attempt <= maxRetries
91+
92+
const options: {
93+
maxRetries: number
94+
backoff: typeof defaultBackoff
95+
retryCondition: typeof defaultRetryCondition
96+
} = {
97+
maxRetries,
5098
backoff: defaultBackoff,
99+
retryCondition: defaultRetryCondition,
51100
...defaultOptions,
52101
...extraOptions,
53102
}
@@ -63,14 +112,26 @@ const retryWithBackoff: BaseQueryEnhancer<
63112
return result
64113
} catch (e: any) {
65114
retry++
66-
if (e.throwImmediately || retry > options.maxRetries) {
115+
116+
if (e.throwImmediately) {
67117
if (e instanceof HandledError) {
68118
return e.value
69119
}
70120

71121
// We don't know what this is, so we have to rethrow it
72122
throw e
73123
}
124+
125+
if (
126+
e instanceof HandledError &&
127+
!options.retryCondition(e.value as FetchBaseQueryError, args, {
128+
attempt: retry,
129+
baseQueryApi: api,
130+
extraOptions,
131+
})
132+
) {
133+
return e.value
134+
}
74135
await options.backoff(retry, options.maxRetries)
75136
}
76137
}

packages/toolkit/src/query/tests/retry.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BaseQueryFn } from '@reduxjs/toolkit/query'
22
import { createApi, retry } from '@reduxjs/toolkit/query'
33
import { setupApiStore, waitMs } from './helpers'
4+
import type { RetryOptions } from '../retry'
45

56
beforeEach(() => {
67
jest.useFakeTimers('legacy')
@@ -339,4 +340,110 @@ describe('configuration', () => {
339340

340341
expect(baseBaseQuery).toHaveBeenCalledTimes(9)
341342
})
343+
344+
test('accepts a custom retryCondition fn', async () => {
345+
const baseBaseQuery = jest.fn<
346+
ReturnType<BaseQueryFn>,
347+
Parameters<BaseQueryFn>
348+
>()
349+
baseBaseQuery.mockResolvedValue({ error: 'rejected' })
350+
351+
const overrideMaxRetries = 3
352+
353+
const baseQuery = retry(baseBaseQuery, {
354+
retryCondition: (_, __, { attempt }) => attempt <= overrideMaxRetries,
355+
})
356+
const api = createApi({
357+
baseQuery,
358+
endpoints: (build) => ({
359+
q1: build.query({
360+
query: () => {},
361+
}),
362+
}),
363+
})
364+
365+
const storeRef = setupApiStore(api, undefined, {
366+
withoutTestLifecycles: true,
367+
})
368+
storeRef.store.dispatch(api.endpoints.q1.initiate({}))
369+
370+
await loopTimers()
371+
372+
expect(baseBaseQuery).toHaveBeenCalledTimes(overrideMaxRetries + 1)
373+
})
374+
375+
test('retryCondition with endpoint config that overrides baseQuery config', async () => {
376+
const baseBaseQuery = jest.fn<
377+
ReturnType<BaseQueryFn>,
378+
Parameters<BaseQueryFn>
379+
>()
380+
baseBaseQuery.mockResolvedValue({ error: 'rejected' })
381+
382+
const baseQuery = retry(baseBaseQuery, {
383+
maxRetries: 10,
384+
})
385+
const api = createApi({
386+
baseQuery,
387+
endpoints: (build) => ({
388+
q1: build.query({
389+
query: () => {},
390+
extraOptions: {
391+
retryCondition: (_, __, { attempt }) => attempt <= 5,
392+
},
393+
}),
394+
}),
395+
})
396+
397+
const storeRef = setupApiStore(api, undefined, {
398+
withoutTestLifecycles: true,
399+
})
400+
storeRef.store.dispatch(api.endpoints.q1.initiate({}))
401+
402+
await loopTimers()
403+
404+
expect(baseBaseQuery).toHaveBeenCalledTimes(6)
405+
})
406+
407+
test('retryCondition also works with mutations', async () => {
408+
const baseBaseQuery = jest.fn<
409+
ReturnType<BaseQueryFn>,
410+
Parameters<BaseQueryFn>
411+
>()
412+
413+
baseBaseQuery
414+
.mockRejectedValueOnce(new Error('rejected'))
415+
.mockRejectedValueOnce(new Error('hello retryCondition'))
416+
.mockRejectedValueOnce(new Error('rejected'))
417+
.mockResolvedValue({ error: 'hello retryCondition' })
418+
419+
const baseQuery = retry(baseBaseQuery, {})
420+
const api = createApi({
421+
baseQuery,
422+
endpoints: (build) => ({
423+
m1: build.mutation({
424+
query: () => ({ method: 'PUT' }),
425+
extraOptions: {
426+
retryCondition: (e) => e.data === 'hello retryCondition',
427+
},
428+
}),
429+
}),
430+
})
431+
432+
const storeRef = setupApiStore(api, undefined, {
433+
withoutTestLifecycles: true,
434+
})
435+
storeRef.store.dispatch(api.endpoints.m1.initiate({}))
436+
437+
await loopTimers()
438+
439+
expect(baseBaseQuery).toHaveBeenCalledTimes(4)
440+
})
441+
442+
test.skip('RetryOptions only accepts one of maxRetries or retryCondition', () => {
443+
// @ts-expect-error Should complain if both exist at once
444+
const ro: RetryOptions = {
445+
maxRetries: 5,
446+
retryCondition: () => false,
447+
}
448+
})
342449
})

0 commit comments

Comments
 (0)