Repository navigation
Expand file tree
/
Copy pathretryer.ts
More file actions
312 lines (278 loc) 路 8.69 KB
/
Copy pathretryer.ts
File metadata and controls
312 lines (278 loc) 路 8.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import { focusManager } from './focusManager'
import { onlineManager } from './onlineManager'
import { isServer as isServerEnvironment } from './environmentManager'
import { noop, sleep } from './utils'
import type { CancelOptions, DefaultError, NetworkMode } from './types'
// TYPES
interface RetryerConfig<TData = unknown, TError = DefaultError> {
fn: () => TData | Promise<TData>
initialPromise?: Promise<TData>
onCancel?: (error: TError) => void
onFail?: (failureCount: number, error: TError) => void
onPause?: () => void
onContinue?: () => void
retry?: RetryValue<TError>
retryDelay?: RetryDelayValue<TError>
networkMode: NetworkMode | undefined
canRun: () => boolean
}
/**
* Runs a function with retries, and can pause, continue, or cancel it. Queries and mutations use
* one for each fetch or execution.
*/
export interface Retryer<TData = unknown> {
/**
* The promise that resolves with the data, or rejects once retries are exhausted or it is
* cancelled.
*/
promise: Promise<TData>
/**
* Cancels the run, rejecting `promise` with a `CancelledError`.
*/
cancel: (cancelOptions?: CancelOptions) => void
/**
* Resumes a paused run, and returns `promise`.
*/
continue: () => Promise<unknown>
/**
* Stops further retries. The current attempt still finishes.
*/
cancelRetry: () => void
/**
* Allows retries again after `cancelRetry`.
*/
continueRetry: () => void
/**
* Returns whether the run can start now, based on the network mode and `canRun`.
*/
canStart: () => boolean
/**
* Starts the run, or waits until it can start, and returns `promise`.
*/
start: () => Promise<TData>
/**
* Returns whether `promise` is still pending, resolved, or rejected.
*/
status: () => 'pending' | 'resolved' | 'rejected'
}
type RetryerStatus = 'pending' | 'resolved' | 'rejected'
/** @inline */
export type RetryValue<TError> = boolean | number | ShouldRetryFunction<TError>
/** @inline */
type ShouldRetryFunction<TError = DefaultError> = (
failureCount: number,
error: TError,
) => boolean
/** @inline */
export type RetryDelayValue<TError> = number | RetryDelayFunction<TError>
/** @inline */
type RetryDelayFunction<TError = DefaultError> = (
failureCount: number,
error: TError,
) => number
/**
* The default `retryDelay`: doubles with every failure, starting at 1 second, up to 30 seconds.
* @param failureCount - The number of failures so far.
* @returns The delay before the next retry, in milliseconds.
*/
function defaultRetryDelay(failureCount: number) {
return Math.min(1000 * 2 ** failureCount, 30000)
}
/**
* Checks whether a fetch can run for the given network mode.
* @param networkMode - The `networkMode` option. Defaults to `'online'`.
* @returns `true` if the network mode is not `'online'`, or if it is and the client is online.
*/
export function canFetch(networkMode: NetworkMode | undefined): boolean {
return (networkMode ?? 'online') === 'online'
? onlineManager.isOnline()
: true
}
/**
* The error thrown by a `Retryer` (and surfaced to `query.promise`/`mutation`) when a fetch is cancelled, e.g. via
* `query.cancel()`. `revert`, if `true`, tells the caller to restore the state the query was in before the fetch
* started instead of surfacing the error. `silent`, if `true`, tells the caller to suppress this error and instead
* resolve with the promise of the fetch that triggered the cancellation.
* @example
* ```ts
* query.cancel()
*
* try {
* await query.promise
* } catch (error) {
* if (error instanceof CancelledError) {
* // the fetch was cancelled, e.g. via `query.cancel()`
* }
* }
* ```
*/
export class CancelledError extends Error {
revert?: boolean
silent?: boolean
constructor(options?: CancelOptions) {
super('CancelledError')
this.revert = options?.revert
this.silent = options?.silent
}
}
/**
* Checks whether a value is a `CancelledError`.
* @param value - The value to check.
* @returns `true` if `value` is a `CancelledError`.
* @deprecated Use instanceof `CancelledError` instead.
*/
export function isCancelledError(value: any): value is CancelledError {
return value instanceof CancelledError
}
/**
* Creates a retryer, which runs a function and retries it on failure according to the `retry` and
* `retryDelay` options. It pauses while the network mode doesn't allow fetching or `canRun` returns
* `false`, and can be cancelled. Queries and mutations use it to run their `queryFn` and
* `mutationFn`.
* @param config - The function to run, the retry options, and the callbacks for failures, pausing,
* continuing, and cancellation.
* @returns The retryer, whose `promise` settles with the result of the function.
*/
export function createRetryer<TData = unknown, TError = DefaultError>(
config: RetryerConfig<TData, TError>,
): Retryer<TData> {
let isRetryCancelled = false
let failureCount = 0
let continueFn: ((value?: unknown) => void) | undefined
let status: RetryerStatus = 'pending'
let promiseResolve: (data: TData) => void
let promiseReject: (error: TError) => void
const promise = new Promise<TData>((resolve, reject) => {
promiseResolve = resolve
promiseReject = reject
})
promise.catch(noop)
const isResolved = () => status !== 'pending'
const cancel = (cancelOptions?: CancelOptions): void => {
if (!isResolved()) {
const error = new CancelledError(cancelOptions) as TError
reject(error)
config.onCancel?.(error)
}
}
const cancelRetry = () => {
isRetryCancelled = true
}
const continueRetry = () => {
isRetryCancelled = false
}
const canContinue = () =>
focusManager.isFocused() &&
(config.networkMode === 'always' || onlineManager.isOnline()) &&
config.canRun()
const canStart = () => canFetch(config.networkMode) && config.canRun()
const resolve = (value: any) => {
if (!isResolved()) {
continueFn?.()
status = 'resolved'
promiseResolve(value)
}
}
const reject = (value: any) => {
if (!isResolved()) {
continueFn?.()
status = 'rejected'
promiseReject(value)
}
}
const pause = (canResume: () => boolean) => {
return new Promise((continueResolve) => {
continueFn = (value) => {
if (isResolved() || canResume()) {
continueResolve(value)
}
}
config.onPause?.()
}).then(() => {
continueFn = undefined
if (!isResolved()) {
config.onContinue?.()
}
})
}
// Create loop function
const run = () => {
// Do nothing if already resolved
if (isResolved()) {
return
}
let promiseOrValue: any
// we can re-use config.initialPromise on the first call of run()
const initialPromise =
failureCount === 0 ? config.initialPromise : undefined
// Execute query
try {
promiseOrValue = initialPromise ?? config.fn()
} catch (error) {
promiseOrValue = Promise.reject(error)
}
Promise.resolve(promiseOrValue)
.then(resolve)
.catch((error) => {
// Stop if the fetch is already resolved
if (isResolved()) {
return
}
// Do we need to retry the request?
const retry = config.retry ?? (isServerEnvironment() ? 0 : 3)
const retryDelay = config.retryDelay ?? defaultRetryDelay
const delay =
typeof retryDelay === 'function'
? retryDelay(failureCount, error)
: retryDelay
const shouldRetry =
retry === true ||
(typeof retry === 'number' && failureCount < retry) ||
(typeof retry === 'function' && retry(failureCount, error))
if (isRetryCancelled || !shouldRetry) {
// We are done if the query does not need to be retried
reject(error)
return
}
failureCount++
// Notify on fail
config.onFail?.(failureCount, error)
// Delay
sleep(delay)
// Pause if the document is not visible or when the device is offline
.then(() => {
return canContinue() ? undefined : pause(canContinue)
})
.then(() => {
if (isRetryCancelled) {
reject(error)
} else {
run()
}
})
})
}
return {
promise,
status: () => status,
cancel,
continue: () => {
continueFn?.()
return promise
},
cancelRetry,
continueRetry,
canStart,
start: () => {
// Start loop
if (canStart()) {
run()
} else {
// Waiting to start (e.g. queued behind a scope) does not depend on focus,
// only on the same conditions that allow starting in the first place
pause(canStart).then(run)
}
return promise
},
}
}