index.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. 'use strict';
  2. export class TimeoutError extends Error {
  3. previous: Error | undefined;
  4. constructor(message: string, previousError?: Error) {
  5. super(message);
  6. this.name = "TimeoutError";
  7. this.previous = previousError;
  8. }
  9. }
  10. export type MatchOption =
  11. | string
  12. | RegExp
  13. | Error
  14. | Function;
  15. export interface Options {
  16. max: number;
  17. timeout?: number | undefined;
  18. match?: MatchOption[] | MatchOption | undefined;
  19. backoffBase?: number | undefined;
  20. backoffExponent?: number | undefined;
  21. backoffJitter?: number | undefined;
  22. report?: ((message: string, obj: CoercedOptions, err?: any) => void) | undefined;
  23. name?: string | undefined;
  24. }
  25. type CoercedOptions = {
  26. $current: number;
  27. max: number;
  28. timeout?: number | undefined;
  29. match: MatchOption[];
  30. backoffBase: number;
  31. backoffExponent: number;
  32. backoffJitter?: number;
  33. report?: ((message: string, obj: CoercedOptions, err?: any) => void) | undefined;
  34. name?: string | undefined;
  35. }
  36. type MaybePromise<T> = PromiseLike<T> | T;
  37. type RetryCallback<T> = ({ current }: { current: CoercedOptions['$current'] }) => MaybePromise<T>;
  38. function matches(match : MatchOption, err: Error) {
  39. if (typeof match === 'function') {
  40. try {
  41. if (err instanceof match) return true;
  42. } catch (_) {
  43. return !!match(err);
  44. }
  45. }
  46. if (match === err.toString()) return true;
  47. if (match === err.message) return true;
  48. return match instanceof RegExp
  49. && (match.test(err.message) || match.test(err.toString()));
  50. }
  51. export function applyJitter(delayMs: number, maxJitterMs: number): number {
  52. const newDelayMs = delayMs + (Math.random() * maxJitterMs * (Math.random() > 0.5 ? 1 : -1));
  53. return Math.max(0, newDelayMs);
  54. }
  55. export function retryAsPromised<T>(callback : RetryCallback<T>, optionsInput : Options | number | CoercedOptions) : Promise<T> {
  56. if (!callback || !optionsInput) {
  57. throw new Error(
  58. 'retry-as-promised must be passed a callback and a options set'
  59. );
  60. }
  61. optionsInput = (typeof optionsInput === "number" ? {max: optionsInput} : optionsInput) as Options | CoercedOptions;
  62. const options : CoercedOptions = {
  63. $current: "$current" in optionsInput ? optionsInput.$current : 1,
  64. max: optionsInput.max,
  65. timeout: optionsInput.timeout || undefined,
  66. match: optionsInput.match ? Array.isArray(optionsInput.match) ? optionsInput.match : [optionsInput.match] : [],
  67. backoffBase: optionsInput.backoffBase === undefined ? 100 : optionsInput.backoffBase,
  68. backoffExponent: optionsInput.backoffExponent || 1.1,
  69. backoffJitter: optionsInput.backoffJitter || 0.0,
  70. report: optionsInput.report,
  71. name: optionsInput.name || callback.name || 'unknown'
  72. };
  73. if (options.match && !Array.isArray(options.match)) options.match = [options.match];
  74. if (options.report) options.report('Trying ' + options.name + ' #' + options.$current + ' at ' + new Date().toLocaleTimeString(), options);
  75. return new Promise(function(resolve, reject) {
  76. let timeout : NodeJS.Timeout | undefined;
  77. let backoffTimeout : NodeJS.Timeout | undefined;
  78. let lastError : Error | undefined;
  79. if (options.timeout) {
  80. timeout = setTimeout(function() {
  81. if (backoffTimeout) clearTimeout(backoffTimeout);
  82. reject(new TimeoutError(options.name + ' timed out', lastError));
  83. }, options.timeout);
  84. }
  85. Promise.resolve(callback({ current: options.$current }))
  86. .then(resolve)
  87. .then(function() {
  88. if (timeout) clearTimeout(timeout);
  89. if (backoffTimeout) clearTimeout(backoffTimeout);
  90. })
  91. .catch(function(err) {
  92. if (timeout) clearTimeout(timeout);
  93. if (backoffTimeout) clearTimeout(backoffTimeout);
  94. lastError = err;
  95. if (options.report) options.report((err && err.toString()) || err, options, err);
  96. // Should not retry if max has been reached
  97. var shouldRetry = options.$current! < options.max;
  98. if (!shouldRetry) return reject(err);
  99. shouldRetry = options.match.length === 0 || options.match.some(function (match) {
  100. return matches(match, err)
  101. });
  102. if (!shouldRetry) return reject(err);
  103. var retryDelay = options.backoffBase * Math.pow(options.backoffExponent, options.$current - 1);
  104. const backoffJitter = options.backoffJitter;
  105. if (backoffJitter !== undefined) {
  106. retryDelay = applyJitter(retryDelay, backoffJitter);
  107. }
  108. // Do some accounting
  109. options.$current++;
  110. if (options.report) options.report(`Retrying ${options.name} (${options.$current})`, options);
  111. if (retryDelay) {
  112. // Use backoff function to ease retry rate
  113. if (options.report) options.report(`Delaying retry of ${options.name} by ${retryDelay}`, options);
  114. backoffTimeout = setTimeout(function() {
  115. retryAsPromised(callback, options)
  116. .then(resolve)
  117. .catch(reject);
  118. }, retryDelay);
  119. } else {
  120. retryAsPromised(callback, options)
  121. .then(resolve)
  122. .catch(reject);
  123. }
  124. });
  125. });
  126. };
  127. export default retryAsPromised;