sharedFetch.js•2.34 kB
const DEFAULT_BACKOFF_MS = 150;
export async function fetchWithRetry(url, options = {}, config = {}) {
const {
timeoutMs = 2000,
retries = 0,
fetchImpl = globalThis.fetch,
backoffMs = DEFAULT_BACKOFF_MS,
} = config;
if (typeof fetchImpl !== 'function') {
throw new Error('fetch implementation is required');
}
let attempt = 0;
let lastError;
while (attempt <= retries) {
const attemptNumber = attempt;
try {
const response = await attemptFetch(url, options, { timeoutMs, fetchImpl });
if (!response.ok) {
const error = new Error(`Request failed with status ${response.status}`);
error.status = response.status;
throw error;
}
return response;
} catch (error) {
lastError = error;
if (attemptNumber === retries) {
throw lastError;
}
const delayMs = backoffMs * 2 ** attemptNumber;
await delay(delayMs);
attempt += 1;
}
}
throw lastError ?? new Error('fetchWithRetry failed');
}
async function attemptFetch(url, options, { timeoutMs, fetchImpl }) {
const controller = new AbortController();
const { signal: userSignal, ...rest } = options;
let timeoutId;
let userAbortHandler;
try {
timeoutId = setTimeout(() => {
controller.abort(new Error('timeout'));
}, timeoutMs);
if (userSignal) {
if (userSignal.aborted) {
const reason = userSignal.reason ?? new Error('Aborted by caller');
controller.abort(reason);
} else {
userAbortHandler = () => {
const reason = userSignal.reason ?? new Error('Aborted by caller');
controller.abort(reason);
};
userSignal.addEventListener('abort', userAbortHandler, { once: true });
}
}
return await fetchImpl(url, { ...rest, signal: controller.signal });
} catch (error) {
if (error?.name === 'AbortError' || error?.message === 'timeout') {
const timeoutError = new Error('Request timed out');
timeoutError.code = 'ETIMEDOUT';
throw timeoutError;
}
throw error;
} finally {
clearTimeout(timeoutId);
if (userSignal && userAbortHandler) {
userSignal.removeEventListener('abort', userAbortHandler);
}
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}