| 1 | import type {
|
|---|
| 2 | BaseQueryApi,
|
|---|
| 3 | BaseQueryArg,
|
|---|
| 4 | BaseQueryEnhancer,
|
|---|
| 5 | BaseQueryError,
|
|---|
| 6 | BaseQueryExtraOptions,
|
|---|
| 7 | BaseQueryFn,
|
|---|
| 8 | BaseQueryMeta,
|
|---|
| 9 | } from './baseQueryTypes'
|
|---|
| 10 | import type { FetchBaseQueryError } from './fetchBaseQuery'
|
|---|
| 11 | import { HandledError } from './HandledError'
|
|---|
| 12 |
|
|---|
| 13 | /**
|
|---|
| 14 | * Exponential backoff based on the attempt number.
|
|---|
| 15 | *
|
|---|
| 16 | * @remarks
|
|---|
| 17 | * 1. 600ms * random(0.4, 1.4)
|
|---|
| 18 | * 2. 1200ms * random(0.4, 1.4)
|
|---|
| 19 | * 3. 2400ms * random(0.4, 1.4)
|
|---|
| 20 | * 4. 4800ms * random(0.4, 1.4)
|
|---|
| 21 | * 5. 9600ms * random(0.4, 1.4)
|
|---|
| 22 | *
|
|---|
| 23 | * @param attempt - Current attempt
|
|---|
| 24 | * @param maxRetries - Maximum number of retries
|
|---|
| 25 | */
|
|---|
| 26 | async function defaultBackoff(
|
|---|
| 27 | attempt: number = 0,
|
|---|
| 28 | maxRetries: number = 5,
|
|---|
| 29 | signal?: AbortSignal,
|
|---|
| 30 | ) {
|
|---|
| 31 | const attempts = Math.min(attempt, maxRetries)
|
|---|
| 32 |
|
|---|
| 33 | const timeout = ~~((Math.random() + 0.4) * (300 << attempts)) // Force a positive int in the case we make this an option
|
|---|
| 34 |
|
|---|
| 35 | await new Promise<void>((resolve, reject) => {
|
|---|
| 36 | const timeoutId = setTimeout(() => resolve(), timeout)
|
|---|
| 37 |
|
|---|
| 38 | // If signal is provided and gets aborted, clear timeout and reject
|
|---|
| 39 | if (signal) {
|
|---|
| 40 | const abortHandler = () => {
|
|---|
| 41 | clearTimeout(timeoutId)
|
|---|
| 42 | reject(new Error('Aborted'))
|
|---|
| 43 | }
|
|---|
| 44 |
|
|---|
| 45 | // Check if already aborted
|
|---|
| 46 | if (signal.aborted) {
|
|---|
| 47 | clearTimeout(timeoutId)
|
|---|
| 48 | reject(new Error('Aborted'))
|
|---|
| 49 | } else {
|
|---|
| 50 | signal.addEventListener('abort', abortHandler, { once: true })
|
|---|
| 51 | }
|
|---|
| 52 | }
|
|---|
| 53 | })
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | type RetryConditionFunction = (
|
|---|
| 57 | error: BaseQueryError<BaseQueryFn>,
|
|---|
| 58 | args: BaseQueryArg<BaseQueryFn>,
|
|---|
| 59 | extraArgs: {
|
|---|
| 60 | attempt: number
|
|---|
| 61 | baseQueryApi: BaseQueryApi
|
|---|
| 62 | extraOptions: BaseQueryExtraOptions<BaseQueryFn> & RetryOptions
|
|---|
| 63 | },
|
|---|
| 64 | ) => boolean
|
|---|
| 65 |
|
|---|
| 66 | export type RetryOptions = {
|
|---|
| 67 | /**
|
|---|
| 68 | * Function used to determine delay between retries
|
|---|
| 69 | */
|
|---|
| 70 | backoff?: (
|
|---|
| 71 | attempt: number,
|
|---|
| 72 | maxRetries: number,
|
|---|
| 73 | signal?: AbortSignal,
|
|---|
| 74 | ) => Promise<void>
|
|---|
| 75 | } & (
|
|---|
| 76 | | {
|
|---|
| 77 | /**
|
|---|
| 78 | * How many times the query will be retried (default: 5)
|
|---|
| 79 | */
|
|---|
| 80 | maxRetries?: number
|
|---|
| 81 | retryCondition?: undefined
|
|---|
| 82 | }
|
|---|
| 83 | | {
|
|---|
| 84 | /**
|
|---|
| 85 | * Callback to determine if a retry should be attempted.
|
|---|
| 86 | * Return `true` for another retry and `false` to quit trying prematurely.
|
|---|
| 87 | */
|
|---|
| 88 | retryCondition?: RetryConditionFunction
|
|---|
| 89 | maxRetries?: undefined
|
|---|
| 90 | }
|
|---|
| 91 | )
|
|---|
| 92 |
|
|---|
| 93 | function fail<BaseQuery extends BaseQueryFn = BaseQueryFn>(
|
|---|
| 94 | error: BaseQueryError<BaseQuery>,
|
|---|
| 95 | meta?: BaseQueryMeta<BaseQuery>,
|
|---|
| 96 | ): never {
|
|---|
| 97 | throw Object.assign(new HandledError({ error, meta }), {
|
|---|
| 98 | throwImmediately: true,
|
|---|
| 99 | })
|
|---|
| 100 | }
|
|---|
| 101 |
|
|---|
| 102 | /**
|
|---|
| 103 | * Checks if the abort signal is aborted and fails immediately if so.
|
|---|
| 104 | * Used to exit retry loops cleanly when a request is aborted.
|
|---|
| 105 | */
|
|---|
| 106 | function failIfAborted(signal: AbortSignal): void {
|
|---|
| 107 | if (signal.aborted) {
|
|---|
| 108 | fail({ status: 'CUSTOM_ERROR', error: 'Aborted' })
|
|---|
| 109 | }
|
|---|
| 110 | }
|
|---|
| 111 |
|
|---|
| 112 | const EMPTY_OPTIONS = {}
|
|---|
| 113 |
|
|---|
| 114 | const retryWithBackoff: BaseQueryEnhancer<
|
|---|
| 115 | unknown,
|
|---|
| 116 | RetryOptions,
|
|---|
| 117 | RetryOptions | void
|
|---|
| 118 | > = (baseQuery, defaultOptions) => async (args, api, extraOptions) => {
|
|---|
| 119 | // We need to figure out `maxRetries` before we define `defaultRetryCondition.
|
|---|
| 120 | // This is probably goofy, but ought to work.
|
|---|
| 121 | // Put our defaults in one array, filter out undefineds, grab the last value.
|
|---|
| 122 | const possibleMaxRetries: number[] = [
|
|---|
| 123 | 5,
|
|---|
| 124 | ((defaultOptions as any) || EMPTY_OPTIONS).maxRetries,
|
|---|
| 125 | ((extraOptions as any) || EMPTY_OPTIONS).maxRetries,
|
|---|
| 126 | ].filter((x) => x !== undefined)
|
|---|
| 127 | const [maxRetries] = possibleMaxRetries.slice(-1)
|
|---|
| 128 |
|
|---|
| 129 | const defaultRetryCondition: RetryConditionFunction = (_, __, { attempt }) =>
|
|---|
| 130 | attempt <= maxRetries
|
|---|
| 131 |
|
|---|
| 132 | const options: {
|
|---|
| 133 | maxRetries: number
|
|---|
| 134 | backoff: typeof defaultBackoff
|
|---|
| 135 | retryCondition: typeof defaultRetryCondition
|
|---|
| 136 | } = {
|
|---|
| 137 | maxRetries,
|
|---|
| 138 | backoff: defaultBackoff,
|
|---|
| 139 | retryCondition: defaultRetryCondition,
|
|---|
| 140 | ...defaultOptions,
|
|---|
| 141 | ...extraOptions,
|
|---|
| 142 | }
|
|---|
| 143 | let retry = 0
|
|---|
| 144 |
|
|---|
| 145 | while (true) {
|
|---|
| 146 | // Check if aborted before each attempt
|
|---|
| 147 | failIfAborted(api.signal)
|
|---|
| 148 |
|
|---|
| 149 | try {
|
|---|
| 150 | const result = await baseQuery(args, api, extraOptions)
|
|---|
| 151 | // baseQueries _should_ return an error property, so we should check for that and throw it to continue retrying
|
|---|
| 152 | if (result.error) {
|
|---|
| 153 | throw new HandledError(result)
|
|---|
| 154 | }
|
|---|
| 155 | return result
|
|---|
| 156 | } catch (e: any) {
|
|---|
| 157 | retry++
|
|---|
| 158 |
|
|---|
| 159 | if (e.throwImmediately) {
|
|---|
| 160 | if (e instanceof HandledError) {
|
|---|
| 161 | return e.value
|
|---|
| 162 | }
|
|---|
| 163 |
|
|---|
| 164 | // We don't know what this is, so we have to rethrow it
|
|---|
| 165 | throw e
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | if (e instanceof HandledError) {
|
|---|
| 169 | if (
|
|---|
| 170 | !options.retryCondition(e.value.error as FetchBaseQueryError, args, {
|
|---|
| 171 | attempt: retry,
|
|---|
| 172 | baseQueryApi: api,
|
|---|
| 173 | extraOptions,
|
|---|
| 174 | })
|
|---|
| 175 | ) {
|
|---|
| 176 | return e.value // Max retries for expected error
|
|---|
| 177 | }
|
|---|
| 178 | } else {
|
|---|
| 179 | // For unexpected errors, respect maxRetries
|
|---|
| 180 | if (retry > options.maxRetries) {
|
|---|
| 181 | // Return the error as a proper error response instead of throwing
|
|---|
| 182 | return { error: e }
|
|---|
| 183 | }
|
|---|
| 184 | }
|
|---|
| 185 |
|
|---|
| 186 | // Check if aborted before backoff
|
|---|
| 187 | failIfAborted(api.signal)
|
|---|
| 188 |
|
|---|
| 189 | try {
|
|---|
| 190 | await options.backoff(retry, options.maxRetries, api.signal)
|
|---|
| 191 | } catch (backoffError) {
|
|---|
| 192 | // If backoff was aborted, exit the retry loop
|
|---|
| 193 | failIfAborted(api.signal)
|
|---|
| 194 | // Otherwise, rethrow the backoff error
|
|---|
| 195 | throw backoffError
|
|---|
| 196 | }
|
|---|
| 197 | }
|
|---|
| 198 | }
|
|---|
| 199 | }
|
|---|
| 200 |
|
|---|
| 201 | /**
|
|---|
| 202 | * A utility that can wrap `baseQuery` in the API definition to provide retries with a basic exponential backoff.
|
|---|
| 203 | *
|
|---|
| 204 | * @example
|
|---|
| 205 | *
|
|---|
| 206 | * ```ts
|
|---|
| 207 | * // codeblock-meta title="Retry every request 5 times by default"
|
|---|
| 208 | * import { createApi, fetchBaseQuery, retry } from '@reduxjs/toolkit/query/react'
|
|---|
| 209 | * interface Post {
|
|---|
| 210 | * id: number
|
|---|
| 211 | * name: string
|
|---|
| 212 | * }
|
|---|
| 213 | * type PostsResponse = Post[]
|
|---|
| 214 | *
|
|---|
| 215 | * // maxRetries: 5 is the default, and can be omitted. Shown for documentation purposes.
|
|---|
| 216 | * const staggeredBaseQuery = retry(fetchBaseQuery({ baseUrl: '/' }), { maxRetries: 5 });
|
|---|
| 217 | * export const api = createApi({
|
|---|
| 218 | * baseQuery: staggeredBaseQuery,
|
|---|
| 219 | * endpoints: (build) => ({
|
|---|
| 220 | * getPosts: build.query<PostsResponse, void>({
|
|---|
| 221 | * query: () => ({ url: 'posts' }),
|
|---|
| 222 | * }),
|
|---|
| 223 | * getPost: build.query<PostsResponse, string>({
|
|---|
| 224 | * query: (id) => ({ url: `post/${id}` }),
|
|---|
| 225 | * extraOptions: { maxRetries: 8 }, // You can override the retry behavior on each endpoint
|
|---|
| 226 | * }),
|
|---|
| 227 | * }),
|
|---|
| 228 | * });
|
|---|
| 229 | *
|
|---|
| 230 | * export const { useGetPostsQuery, useGetPostQuery } = api;
|
|---|
| 231 | * ```
|
|---|
| 232 | */
|
|---|
| 233 | export const retry = /* @__PURE__ */ Object.assign(retryWithBackoff, { fail })
|
|---|