index.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import isNetworkError from 'is-network-error';
  2. function validateRetries(retries) {
  3. if (typeof retries === 'number') {
  4. if (retries < 0) {
  5. throw new TypeError('Expected `retries` to be a non-negative number.');
  6. }
  7. if (Number.isNaN(retries)) {
  8. throw new TypeError('Expected `retries` to be a valid number or Infinity, got NaN.');
  9. }
  10. } else if (retries !== undefined) {
  11. throw new TypeError('Expected `retries` to be a number or Infinity.');
  12. }
  13. }
  14. function validateNumberOption(name, value, {min = 0, allowInfinity = false} = {}) {
  15. if (value === undefined) {
  16. return;
  17. }
  18. if (typeof value !== 'number' || Number.isNaN(value)) {
  19. throw new TypeError(`Expected \`${name}\` to be a number${allowInfinity ? ' or Infinity' : ''}.`);
  20. }
  21. if (!allowInfinity && !Number.isFinite(value)) {
  22. throw new TypeError(`Expected \`${name}\` to be a finite number.`);
  23. }
  24. if (value < min) {
  25. throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`);
  26. }
  27. }
  28. function validateFunctionOption(name, value) {
  29. if (value === undefined) {
  30. return;
  31. }
  32. if (typeof value !== 'function') {
  33. throw new TypeError(`Expected \`${name}\` to be a function.`);
  34. }
  35. }
  36. export class AbortError extends Error {
  37. constructor(message) {
  38. super();
  39. if (message instanceof Error) {
  40. this.originalError = message;
  41. ({message} = message);
  42. } else {
  43. this.originalError = new Error(message);
  44. this.originalError.stack = this.stack;
  45. }
  46. this.name = 'AbortError';
  47. this.message = message;
  48. }
  49. }
  50. function calculateDelay(retriesConsumed, options) {
  51. const attempt = Math.max(1, retriesConsumed + 1);
  52. const random = options.randomize ? (Math.random() + 1) : 1;
  53. let timeout = Math.round(random * options.minTimeout * (options.factor ** (attempt - 1)));
  54. timeout = Math.min(timeout, options.maxTimeout);
  55. return timeout;
  56. }
  57. function calculateRemainingTime(start, max) {
  58. if (!Number.isFinite(max)) {
  59. return max;
  60. }
  61. return max - (performance.now() - start);
  62. }
  63. async function delayForRetry(delay, options) {
  64. if (delay <= 0) {
  65. return;
  66. }
  67. await new Promise((resolve, reject) => {
  68. const onAbort = () => {
  69. clearTimeout(timeoutToken);
  70. options.signal?.removeEventListener('abort', onAbort);
  71. reject(options.signal.reason);
  72. };
  73. const timeoutToken = setTimeout(() => {
  74. options.signal?.removeEventListener('abort', onAbort);
  75. resolve();
  76. }, delay);
  77. if (options.unref) {
  78. timeoutToken.unref?.();
  79. }
  80. options.signal?.addEventListener('abort', onAbort, {once: true});
  81. });
  82. }
  83. async function onAttemptFailure({error, attemptNumber, retriesConsumed, startTime, options}) {
  84. const normalizedError = error instanceof Error
  85. ? error
  86. : new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`);
  87. if (normalizedError instanceof AbortError) {
  88. throw normalizedError.originalError;
  89. }
  90. const retriesLeft = Number.isFinite(options.retries)
  91. ? Math.max(0, options.retries - retriesConsumed)
  92. : options.retries;
  93. const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY;
  94. const delayTime = calculateDelay(retriesConsumed, options);
  95. const remainingTimeBeforeCallbacks = calculateRemainingTime(startTime, maxRetryTime);
  96. if (remainingTimeBeforeCallbacks <= 0) {
  97. const context = Object.freeze({
  98. error: normalizedError,
  99. attemptNumber,
  100. retriesLeft,
  101. retriesConsumed,
  102. retryDelay: 0,
  103. });
  104. await options.onFailedAttempt(context);
  105. throw normalizedError;
  106. }
  107. const consumeRetryContext = Object.freeze({
  108. error: normalizedError,
  109. attemptNumber,
  110. retriesLeft,
  111. retriesConsumed,
  112. retryDelay: retriesLeft > 0 ? delayTime : 0,
  113. });
  114. const consumeRetry = await options.shouldConsumeRetry(consumeRetryContext);
  115. const effectiveDelay = consumeRetry && retriesLeft > 0 ? delayTime : 0;
  116. const context = Object.freeze({
  117. error: normalizedError,
  118. attemptNumber,
  119. retriesLeft,
  120. retriesConsumed,
  121. retryDelay: effectiveDelay,
  122. });
  123. await options.onFailedAttempt(context);
  124. if (calculateRemainingTime(startTime, maxRetryTime) <= 0) {
  125. throw normalizedError;
  126. }
  127. const remainingTime = calculateRemainingTime(startTime, maxRetryTime);
  128. if (remainingTime <= 0 || retriesLeft <= 0) {
  129. throw normalizedError;
  130. }
  131. if (normalizedError instanceof TypeError && !isNetworkError(normalizedError)) {
  132. throw normalizedError;
  133. }
  134. if (!await options.shouldRetry(context)) {
  135. throw normalizedError;
  136. }
  137. const remainingTimeAfterShouldRetry = calculateRemainingTime(startTime, maxRetryTime);
  138. if (remainingTimeAfterShouldRetry <= 0) {
  139. throw normalizedError;
  140. }
  141. if (!consumeRetry) {
  142. options.signal?.throwIfAborted();
  143. return false;
  144. }
  145. const finalDelay = Math.min(effectiveDelay, remainingTimeAfterShouldRetry);
  146. options.signal?.throwIfAborted();
  147. await delayForRetry(finalDelay, options);
  148. options.signal?.throwIfAborted();
  149. return true;
  150. }
  151. export default async function pRetry(input, options = {}) {
  152. options = {...options};
  153. validateRetries(options.retries);
  154. if (Object.hasOwn(options, 'forever')) {
  155. throw new Error('The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.');
  156. }
  157. options.retries ??= 10;
  158. options.factor ??= 2;
  159. options.minTimeout ??= 1000;
  160. options.maxTimeout ??= Number.POSITIVE_INFINITY;
  161. options.maxRetryTime ??= Number.POSITIVE_INFINITY;
  162. options.randomize ??= false;
  163. options.onFailedAttempt ??= () => {};
  164. options.shouldRetry ??= () => true;
  165. options.shouldConsumeRetry ??= () => true;
  166. // Validate numeric options and normalize edge cases
  167. validateFunctionOption('onFailedAttempt', options.onFailedAttempt);
  168. validateFunctionOption('shouldRetry', options.shouldRetry);
  169. validateFunctionOption('shouldConsumeRetry', options.shouldConsumeRetry);
  170. validateNumberOption('factor', options.factor, {min: 0, allowInfinity: false});
  171. validateNumberOption('minTimeout', options.minTimeout, {min: 0, allowInfinity: false});
  172. validateNumberOption('maxTimeout', options.maxTimeout, {min: 0, allowInfinity: true});
  173. validateNumberOption('maxRetryTime', options.maxRetryTime, {min: 0, allowInfinity: true});
  174. // Treat non-positive factor as 1 to avoid zero backoff or negative behavior
  175. if (!(options.factor > 0)) {
  176. options.factor = 1;
  177. }
  178. options.signal?.throwIfAborted();
  179. let attemptNumber = 0;
  180. let retriesConsumed = 0;
  181. const startTime = performance.now();
  182. while (Number.isFinite(options.retries) ? retriesConsumed <= options.retries : true) {
  183. attemptNumber++;
  184. try {
  185. options.signal?.throwIfAborted();
  186. const result = await input(attemptNumber);
  187. options.signal?.throwIfAborted();
  188. return result;
  189. } catch (error) {
  190. if (await onAttemptFailure({
  191. error,
  192. attemptNumber,
  193. retriesConsumed,
  194. startTime,
  195. options,
  196. })) {
  197. retriesConsumed++;
  198. }
  199. }
  200. }
  201. // Should not reach here, but in case it does, throw an error
  202. throw new Error('Retry attempts exhausted without throwing an error.');
  203. }
  204. export function makeRetriable(function_, options) {
  205. return function (...arguments_) {
  206. return pRetry(() => function_.apply(this, arguments_), options);
  207. };
  208. }