UNPKG

p-retry

Version:

Retry a promise-returning or async function

199 lines (153 loc) 5.58 kB
import isNetworkError from 'is-network-error'; function validateRetries(retries) { if (typeof retries === 'number') { if (retries < 0) { throw new TypeError('Expected `retries` to be a non-negative number.'); } if (Number.isNaN(retries)) { throw new TypeError('Expected `retries` to be a valid number or Infinity, got NaN.'); } } else if (retries !== undefined) { throw new TypeError('Expected `retries` to be a number or Infinity.'); } } function validateNumberOption(name, value, {min = 0, allowInfinity = false} = {}) { if (value === undefined) { return; } if (typeof value !== 'number' || Number.isNaN(value)) { throw new TypeError(`Expected \`${name}\` to be a number${allowInfinity ? ' or Infinity' : ''}.`); } if (!allowInfinity && !Number.isFinite(value)) { throw new TypeError(`Expected \`${name}\` to be a finite number.`); } if (value < min) { throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`); } } export class AbortError extends Error { constructor(message) { super(); if (message instanceof Error) { this.originalError = message; ({message} = message); } else { this.originalError = new Error(message); this.originalError.stack = this.stack; } this.name = 'AbortError'; this.message = message; } } const createRetryContext = (error, attemptNumber, options) => { // Minus 1 from attemptNumber because the first attempt does not count as a retry const retriesLeft = options.retries - (attemptNumber - 1); return Object.freeze({ error, attemptNumber, retriesLeft, }); }; function calculateDelay(attempt, options) { const random = options.randomize ? (Math.random() + 1) : 1; let timeout = Math.round(random * Math.max(options.minTimeout, 1) * (options.factor ** (attempt - 1))); timeout = Math.min(timeout, options.maxTimeout); return timeout; } async function onAttemptFailure(error, attemptNumber, options, startTime, maxRetryTime) { let normalizedError = error; if (!(normalizedError instanceof Error)) { normalizedError = new TypeError(`Non-error was thrown: "${normalizedError}". You should only throw errors.`); } if (normalizedError instanceof AbortError) { throw normalizedError.originalError; } if (normalizedError instanceof TypeError && !isNetworkError(normalizedError)) { throw normalizedError; } const context = createRetryContext(normalizedError, attemptNumber, options); // Always call onFailedAttempt await options.onFailedAttempt(context); const currentTime = Date.now(); if ( currentTime - startTime >= maxRetryTime || attemptNumber >= options.retries + 1 || !(await options.shouldRetry(context)) ) { throw normalizedError; // Do not retry, throw the original error } // Calculate delay before next attempt const delayTime = calculateDelay(attemptNumber, options); // Ensure that delay does not exceed maxRetryTime const timeLeft = maxRetryTime - (currentTime - startTime); if (timeLeft <= 0) { throw normalizedError; // Max retry time exceeded } const finalDelay = Math.min(delayTime, timeLeft); // Introduce delay if (finalDelay > 0) { await new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timeoutToken); options.signal?.removeEventListener('abort', onAbort); reject(options.signal.reason); }; const timeoutToken = setTimeout(() => { options.signal?.removeEventListener('abort', onAbort); resolve(); }, finalDelay); if (options.unref) { timeoutToken.unref?.(); } options.signal?.addEventListener('abort', onAbort, {once: true}); }); } options.signal?.throwIfAborted(); } export default async function pRetry(input, options = {}) { options = {...options}; validateRetries(options.retries); if (Object.hasOwn(options, 'forever')) { throw new Error('The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.'); } options.retries ??= 10; options.factor ??= 2; options.minTimeout ??= 1000; options.maxTimeout ??= Number.POSITIVE_INFINITY; options.randomize ??= false; options.onFailedAttempt ??= () => {}; options.shouldRetry ??= () => true; // Validate numeric options and normalize edge cases validateNumberOption('factor', options.factor, {min: 0, allowInfinity: false}); validateNumberOption('minTimeout', options.minTimeout, {min: 0, allowInfinity: false}); validateNumberOption('maxTimeout', options.maxTimeout, {min: 0, allowInfinity: true}); const resolvedMaxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY; validateNumberOption('maxRetryTime', resolvedMaxRetryTime, {min: 0, allowInfinity: true}); // Treat non-positive factor as 1 to avoid zero backoff or negative behavior if (!(options.factor > 0)) { options.factor = 1; } options.signal?.throwIfAborted(); let attemptNumber = 0; const startTime = Date.now(); // Use validated local value const maxRetryTime = resolvedMaxRetryTime; while (attemptNumber < options.retries + 1) { attemptNumber++; try { options.signal?.throwIfAborted(); const result = await input(attemptNumber); options.signal?.throwIfAborted(); return result; } catch (error) { await onAttemptFailure(error, attemptNumber, options, startTime, maxRetryTime); } } // Should not reach here, but in case it does, throw an error throw new Error('Retry attempts exhausted without throwing an error.'); } export function makeRetriable(function_, options) { return function (...arguments_) { return pRetry(() => function_.apply(this, arguments_), options); }; }