UNPKG

@lifeomic/attempt

Version:

Library that can be used to retry functions that return promise

155 lines (154 loc) 6.17 kB
function applyDefaults(options) { if (!options) { options = {}; } return { delay: (options.delay === undefined) ? 200 : options.delay, initialDelay: (options.initialDelay === undefined) ? 0 : options.initialDelay, minDelay: (options.minDelay === undefined) ? 0 : options.minDelay, maxDelay: (options.maxDelay === undefined) ? 0 : options.maxDelay, factor: (options.factor === undefined) ? 0 : options.factor, maxAttempts: (options.maxAttempts === undefined) ? 3 : options.maxAttempts, timeout: (options.timeout === undefined) ? 0 : options.timeout, jitter: (options.jitter === true), initialJitter: (options.initialJitter === true), handleError: (options.handleError === undefined) ? null : options.handleError, handleTimeout: (options.handleTimeout === undefined) ? null : options.handleTimeout, beforeAttempt: (options.beforeAttempt === undefined) ? null : options.beforeAttempt, calculateDelay: (options.calculateDelay === undefined) ? null : options.calculateDelay }; } export async function sleep(delay) { return new Promise((resolve) => setTimeout(resolve, delay)); } export function defaultCalculateDelay(context, options) { let delay = options.delay; if (delay === 0) { // no delay between attempts return 0; } if (options.factor) { delay *= Math.pow(options.factor, context.attemptNum - 1); if (options.maxDelay !== 0) { delay = Math.min(delay, options.maxDelay); } } if (options.jitter) { // Jitter will result in a random value between `minDelay` and // calculated delay for a given attempt. // See https://www.awsarchitectureblog.com/2015/03/backoff.html // We're using the "full jitter" strategy. const min = Math.ceil(options.minDelay); const max = Math.floor(delay); delay = Math.floor(Math.random() * (max - min + 1)) + min; } return Math.round(delay); } export async function retry(attemptFunc, attemptOptions) { const options = applyDefaults(attemptOptions); for (const prop of [ 'delay', 'initialDelay', 'minDelay', 'maxDelay', 'maxAttempts', 'timeout' ]) { const value = options[prop]; if (!Number.isInteger(value) || (value < 0)) { throw new Error(`Value for ${prop} must be an integer greater than or equal to 0`); } } if ((options.factor.constructor !== Number) || (options.factor < 0)) { throw new Error(`Value for factor must be a number greater than or equal to 0`); } if (options.delay < options.minDelay) { throw new Error(`delay cannot be less than minDelay (delay: ${options.delay}, minDelay: ${options.minDelay}`); } const context = { attemptNum: 0, attemptsRemaining: options.maxAttempts ? options.maxAttempts : -1, aborted: false, abort() { context.aborted = true; } }; const calculateDelay = options.calculateDelay || defaultCalculateDelay; async function makeAttempt() { if (options.beforeAttempt) { options.beforeAttempt(context, options); } if (context.aborted) { const err = new Error(`Attempt aborted`); err.code = 'ATTEMPT_ABORTED'; throw err; } const onError = async (err) => { if (options.handleError) { await options.handleError(err, context, options); } if (context.aborted || (context.attemptsRemaining === 0)) { throw err; } // We are about to try again so increment attempt number context.attemptNum++; const delay = calculateDelay(context, options); if (delay) { await sleep(delay); } return makeAttempt(); }; if (context.attemptsRemaining > 0) { context.attemptsRemaining--; } if (options.timeout) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (options.handleTimeout) { // If calling handleTimeout throws an error that is not wrapped in a promise // we want to catch the error and reject. try { resolve(options.handleTimeout(context, options)); } catch (e) { reject(e); } } else { const err = new Error(`Retry timeout (attemptNum: ${context.attemptNum}, timeout: ${options.timeout})`); err.code = 'ATTEMPT_TIMEOUT'; reject(err); } }, options.timeout); attemptFunc(context, options).then((result) => { clearTimeout(timer); resolve(result); }).catch((err) => { clearTimeout(timer); // Calling resolve with a Promise that rejects here will result // in an unhandled rejection. Calling `reject` with errors // does not result in an unhandled rejection onError(err).then(resolve).catch(reject); }); }); } else { // No timeout provided so wait indefinitely for the returned promise // to be resolved. return attemptFunc(context, options).catch(onError); } } const initialDelay = options.calculateDelay ? options.calculateDelay(context, options) : options.initialDelay; if (initialDelay) { await sleep(initialDelay); } if (context.attemptNum < 1 && options.initialJitter) { const delay = calculateDelay(context, options); if (delay) { await sleep(delay); } } return makeAttempt(); }