forked from Liquifact/Liquifact-frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfetchWithRetry.js
More file actions
182 lines (158 loc) · 5.97 KB
/
Copy pathfetchWithRetry.js
File metadata and controls
182 lines (158 loc) · 5.97 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
/**
* @file Retry-with-exponential-backoff wrapper for the native fetch API.
*
* Provides a configurable `fetchWithRetry` function that retries failed HTTP
* requests on transient errors (network failures and 5xx server errors) using
* exponential backoff with jitter. 4xx client errors are never retried because
* they indicate a problem with the request itself.
*
* @module fetchWithRetry
*/
/**
* Default delay function: exponential backoff with full jitter.
* delay = random(0, baseDelay * 2^attempt)
* This spreads retries from multiple clients nicely.
*
* @param {number} attempt - Zero-based attempt counter.
* @param {number} baseDelayMs - Base delay in milliseconds.
* @returns {number} Delay in milliseconds before the next retry.
*/
function defaultDelay(attempt, baseDelayMs) {
const maxDelay = baseDelayMs * Math.pow(2, attempt);
return Math.random() * maxDelay;
}
/**
* Default retry predicate: only retry on network errors or 5xx server errors.
*
* @param {Error | null} error - The error from the rejected fetch, or null if fetch resolved.
* @param {Response | null} response - The Response object, or null if fetch rejected.
* @returns {boolean} True if the request should be retried.
*/
function defaultShouldRetry(error, response) {
// Network errors (fetch rejected) are always worth retrying.
if (error) return true;
// Only retry 5xx server errors.
if (response && response.status >= 500 && response.status < 600) return true;
return false;
}
/**
* Determines if an HTTP method is generally considered idempotent.
* Non-idempotent methods (POST, PATCH, DELETE) are NOT retried by default
* because replaying them could cause duplicate side effects.
*
* @param {string} method - HTTP method (upper-cased internally).
* @returns {boolean} True if the method is idempotent.
*/
function isIdempotentMethod(method) {
return ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"].includes(method.toUpperCase());
}
/**
* Promise-based sleep that can be cancelled via an AbortSignal.
*
* @param {number} ms - Milliseconds to sleep.
* @param {AbortSignal|null} signal - Optional AbortSignal to cancel the sleep.
* @returns {Promise<void>}
*/
export function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
return reject(new DOMException("The operation was aborted.", "AbortError"));
}
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
function cleanup() {
if (signal) {
signal.removeEventListener("abort", onAbort);
}
}
function onAbort() {
clearTimeout(timer);
reject(new DOMException("The operation was aborted.", "AbortError"));
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
/**
* Wraps the native fetch API with configurable retry logic using exponential
* backoff with jitter.
*
* @param {string} url - The URL to fetch.
* @param {object} [options] - Standard fetch options (method, headers, body, signal, etc.).
* @param {object} [retryOptions] - Retry configuration.
* @param {number} [retryOptions.maxAttempts=3] - Maximum number of fetch attempts.
* The first call counts as attempt #1, so total retries = maxAttempts - 1.
* @param {number} [retryOptions.baseDelayMs=1000] - Base delay in milliseconds
* for the exponential backoff calculation.
* @param {function} [retryOptions.delayFn] - Custom delay function.
* Signature: (attempt: number, baseDelayMs: number) => number
* @param {function} [retryOptions.shouldRetry] - Custom retry predicate.
* Signature: (error: Error | null, response: Response | null) => boolean
* @param {boolean} [retryOptions.retryNonIdempotent=false] - If true, also retry
* non-idempotent methods (POST, PATCH). Defaults to false.
* @returns {Promise<Response>} A promise that resolves with the final Response
* or rejects with the last error encountered.
*/
export async function fetchWithRetry(url, options = {}, retryOptions = {}) {
const {
maxAttempts = 3,
baseDelayMs = 1000,
delayFn = defaultDelay,
shouldRetry = defaultShouldRetry,
retryNonIdempotent = false,
} = retryOptions;
const originalSignal = options.signal || null;
const method = (options.method || "GET").toUpperCase();
// Non-idempotent methods bypass retry unless explicitly configured otherwise.
if (!isIdempotentMethod(method) && !retryNonIdempotent) {
return fetch(url, options);
}
let lastError = null;
let lastResponse = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// If the original signal was aborted, stop immediately.
if (originalSignal && originalSignal.aborted) {
throw new DOMException("The operation was aborted.", "AbortError");
}
try {
const response = await fetch(url, options);
// Success — return immediately.
if (response.ok) {
return response;
}
lastResponse = response;
// If this was our last attempt, return the response as-is.
if (attempt >= maxAttempts - 1) {
return response;
}
// Check if this response status warrants a retry.
if (!shouldRetry(null, response)) {
return response;
}
} catch (err) {
lastError = err;
// AbortError is never retried.
if (err.name === "AbortError") {
throw err;
}
// If this was our last attempt, re-throw the error.
if (attempt >= maxAttempts - 1) {
throw err;
}
// Check if this error warrants a retry.
if (!shouldRetry(err, null)) {
throw err;
}
}
// Wait before the next attempt using backoff delay.
await sleep(delayFn(attempt, baseDelayMs), originalSignal);
}
// Should never reach here, but satisfy the control-flow analyser.
if (lastError) throw lastError;
if (lastResponse) return lastResponse;
throw new Error("Unexpected: fetchWithRetry reached end without resolution");
}
export { defaultDelay, defaultShouldRetry, isIdempotentMethod };