From 4acb7838b66793ddbabdfe8ada2afb2125c36988 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 12 Oct 2024 13:27:14 +0700 Subject: [PATCH] Rewrite the package to not depend on `retry` --- index.js | 152 +++++++++++++++++++++++++++++++-------------------- package.json | 9 ++- 2 files changed, 98 insertions(+), 63 deletions(-) diff --git a/index.js b/index.js index 3a2f2c6..e86380c 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,3 @@ -import retry from 'retry'; import isNetworkError from 'is-network-error'; export class AbortError extends Error { @@ -27,68 +26,101 @@ const decorateErrorWithCounts = (error, attemptNumber, options) => { return error; }; -export default async function pRetry(input, options) { - return new Promise((resolve, reject) => { - options = { - onFailedAttempt() {}, - retries: 10, - shouldRetry: () => true, - ...options, - }; +function calculateDelay(attempt, options) { + const random = options.randomize ? (Math.random() + 1) : 1; - const operation = retry.operation(options); + let timeout = Math.round(random * Math.max(options.minTimeout, 1) * (options.factor ** (attempt - 1))); + timeout = Math.min(timeout, options.maxTimeout); - const abortHandler = () => { - operation.stop(); - reject(options.signal?.reason); - }; + return timeout; +} - if (options.signal && !options.signal.aborted) { - options.signal.addEventListener('abort', abortHandler, {once: true}); - } +export default async function pRetry(input, options = {}) { + options = { + retries: 10, + factor: 2, + minTimeout: 1000, + maxTimeout: Number.POSITIVE_INFINITY, + randomize: false, + onFailedAttempt() {}, + shouldRetry: () => true, + ...options, + }; + + options.signal?.throwIfAborted(); + + let attemptNumber = 0; + const startTime = Date.now(); + + const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY; + + while (attemptNumber < options.retries + 1) { + attemptNumber++; + + try { + options.signal?.throwIfAborted(); + + const result = await input(attemptNumber); - const cleanUp = () => { - options.signal?.removeEventListener('abort', abortHandler); - operation.stop(); - }; - - operation.attempt(async attemptNumber => { - try { - const result = await input(attemptNumber); - cleanUp(); - resolve(result); - } catch (error) { - try { - if (!(error instanceof Error)) { - throw new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`); - } - - if (error instanceof AbortError) { - throw error.originalError; - } - - if (error instanceof TypeError && !isNetworkError(error)) { - throw error; - } - - decorateErrorWithCounts(error, attemptNumber, options); - - if (!(await options.shouldRetry(error))) { - operation.stop(); - reject(error); - } - - await options.onFailedAttempt(error); - - if (!operation.retry(error)) { - throw operation.mainError(); - } - } catch (finalError) { - decorateErrorWithCounts(finalError, attemptNumber, options); - cleanUp(); - reject(finalError); - } + options.signal?.throwIfAborted(); + + return result; + } catch (catchError) { + let error = catchError; + + if (!(error instanceof Error)) { + error = new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`); + } + + if (error instanceof AbortError) { + throw error.originalError; } - }); - }); + + if (error instanceof TypeError && !isNetworkError(error)) { + throw error; + } + + decorateErrorWithCounts(error, attemptNumber, options); + + // Always call onFailedAttempt + await options.onFailedAttempt(error); + + const currentTime = Date.now(); + if ( + currentTime - startTime >= maxRetryTime + || attemptNumber >= options.retries + 1 + || !(await options.shouldRetry(error)) + ) { + throw error; // 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 error; // Max retry time exceeded + } + + const finalDelay = Math.min(delayTime, timeLeft); + + // Introduce delay + if (finalDelay > 0) { + await new Promise((resolve, reject) => { + const timeoutToken = setTimeout(resolve, finalDelay); + + options.signal?.addEventListener('abort', () => { + clearTimeout(timeoutToken); + reject(options.signal.reason); + }, {once: true}); + }); + } + + options.signal?.throwIfAborted(); + } + } + + // Should not reach here, but in case it does, throw an error + throw new Error('Retry attempts exhausted without throwing an error.'); } diff --git a/package.json b/package.json index fdb3bf1..5e0d242 100644 --- a/package.json +++ b/package.json @@ -44,14 +44,17 @@ "bluebird" ], "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "is-network-error": "^1.0.0" }, "devDependencies": { "ava": "^5.3.1", "delay": "^6.0.0", "tsd": "^0.28.1", "xo": "^0.56.0" + }, + "xo": { + "rules": { + "no-await-in-loop": "off" + } } }