UNPKG

@es-labs/node

Version:
180 lines (153 loc) 7 kB
/** * Fetch with AbortController timeout + retry mechanism * * Features: * - Per-attempt timeout via AbortController * - Exponential backoff with jitter between retries * - Retries on network errors and configurable HTTP status codes * - External abort signal support (cancel from outside) * - Retry lifecycle hooks (onRetry) */ // ─── Default Config ─────────────────────────────────────────────────────────── const DEFAULTS = { timeout: 10_000, // ms per attempt before aborting retries: 3, // max retry attempts (0 = no retries) baseDelay: 300, // ms — base for exponential backoff maxDelay: 10_000, // ms — cap on backoff delay backoffFactor: 2, // exponential multiplier jitter: true, // randomise delay to avoid thundering herd retryOn: [408, 429, 500, 502, 503, 504], // HTTP codes to retry on onRetry: null, // ({ attempt, error, delay }) => void }; // ─── Core ───────────────────────────────────────────────────────────────────── /** * fetch() with per-attempt timeout and automatic retry on failure. * * @param {string} url * @param {object} [options] - All standard fetch options, plus: * @param {number} [options.timeout] - Ms before each attempt times out * @param {number} [options.retries] - Max number of retries after first failure * @param {number} [options.baseDelay] - Initial backoff delay in ms * @param {number} [options.maxDelay] - Max backoff delay in ms * @param {number} [options.backoffFactor] - Exponential backoff multiplier * @param {boolean} [options.jitter] - Add randomness to delay * @param {number[]} [options.retryOn] - HTTP status codes that trigger a retry * @param {Function} [options.onRetry] - Called before each retry attempt * @param {AbortSignal} [options.signal] - External signal to cancel all attempts * @returns {Promise<Response>} */ async function fetchWithRetry(url, options = {}) { const { timeout, retries, baseDelay, maxDelay, backoffFactor, jitter, retryOn, onRetry, signal: externalSignal, // caller-supplied cancel signal ...fetchOptions // remaining standard fetch options } = { ...DEFAULTS, ...options }; let attempt = 0; while (true) { // Honour external cancellation before starting each attempt if (externalSignal?.aborted) { throw new DOMException('Fetch aborted by caller', 'AbortError'); } // Create a per-attempt AbortController for the timeout const timeoutController = new AbortController(); const timeoutId = setTimeout(() => timeoutController.abort(), timeout); // Merge the timeout signal with any external signal const signal = externalSignal ? mergeSignals([timeoutController.signal, externalSignal]) : timeoutController.signal; let response; let error; try { response = await fetch(url, { ...fetchOptions, signal }); } catch (err) { error = err; } finally { clearTimeout(timeoutId); } // ── Determine if we should retry ──────────────────────────────────────── const isTimeout = error?.name === 'AbortError' && timeoutController.signal.aborted; const isNetworkErr = error && !isTimeout; const isExternalAbort = externalSignal?.aborted; // Never retry if the caller explicitly cancelled if (isExternalAbort) { throw new DOMException('Fetch aborted by caller', 'AbortError'); } const shouldRetry = attempt < retries && (isTimeout || isNetworkErr || (response && retryOn.includes(response.status))); if (!shouldRetry) { // No more retries — resolve or throw if (error) throw error; return response; } // ── Backoff before next attempt ────────────────────────────────────────── const delay = calcDelay({ attempt, baseDelay, maxDelay, backoffFactor, jitter }); // Check Retry-After header on 429/503 and honour it if present const retryAfterMs = parseRetryAfter(response?.headers?.get('Retry-After')); const waitMs = retryAfterMs ? Math.min(retryAfterMs, maxDelay) : delay; const retryReason = isTimeout ? `Timeout after ${timeout}ms` : isNetworkErr ? error.message : `HTTP ${response.status}`; if (typeof onRetry === 'function') { onRetry({ attempt: attempt + 1, total: retries, reason: retryReason, delay: waitMs }); } await sleep(waitMs, externalSignal); // sleep is also cancellable attempt++; } } // ─── Helpers ────────────────────────────────────────────────────────────────── /** Exponential backoff with optional jitter */ function calcDelay({ attempt, baseDelay, maxDelay, backoffFactor, jitter }) { const exponential = baseDelay * Math.pow(backoffFactor, attempt); const capped = Math.min(exponential, maxDelay); return jitter ? capped * (0.5 + Math.random() * 0.5) // jitter: 50%–100% of capped : capped; } /** * Merge multiple AbortSignals into one. * Aborts as soon as ANY of the signals fires. * (Native AbortSignal.any() is available in Node 20+ / Chrome 116+) */ function mergeSignals(signals) { if (typeof AbortSignal.any === 'function') { return AbortSignal.any(signals); } // Polyfill for older environments const controller = new AbortController(); for (const signal of signals) { if (signal.aborted) { controller.abort(signal.reason); break; } signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); } return controller.signal; } /** Sleep for ms, but cancel early if signal fires */ function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError')); const id = setTimeout(resolve, ms); signal?.addEventListener('abort', () => { clearTimeout(id); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }); }); } /** Parse Retry-After header into milliseconds (supports seconds int and HTTP date) */ function parseRetryAfter(header) { if (!header) return null; const seconds = parseInt(header, 10); if (!isNaN(seconds)) return seconds * 1000; const date = new Date(header).getTime(); if (!isNaN(date)) return Math.max(0, date - Date.now()); return null; } export { fetchWithRetry };