retrier.cjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. 'use strict';
  2. /**
  3. * @fileoverview A utility for retrying failed async method calls.
  4. */
  5. /* global setTimeout, clearTimeout */
  6. //-----------------------------------------------------------------------------
  7. // Constants
  8. //-----------------------------------------------------------------------------
  9. const MAX_TASK_TIMEOUT = 60000;
  10. const MAX_TASK_DELAY = 100;
  11. //-----------------------------------------------------------------------------
  12. // Helpers
  13. //-----------------------------------------------------------------------------
  14. /*
  15. * The following logic has been extracted from graceful-fs.
  16. *
  17. * The ISC License
  18. *
  19. * Copyright (c) 2011-2023 Isaac Z. Schlueter, Ben Noordhuis, and Contributors
  20. *
  21. * Permission to use, copy, modify, and/or distribute this software for any
  22. * purpose with or without fee is hereby granted, provided that the above
  23. * copyright notice and this permission notice appear in all copies.
  24. *
  25. * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
  26. * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
  27. * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
  28. * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
  29. * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
  30. * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
  31. * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  32. */
  33. /**
  34. * Checks if it is time to retry a task based on the timestamp and last attempt time.
  35. * @param {RetryTask} task The task to check.
  36. * @param {number} maxDelay The maximum delay for the queue.
  37. * @returns {boolean} true if it is time to retry, false otherwise.
  38. */
  39. function isTimeToRetry(task, maxDelay) {
  40. const timeSinceLastAttempt = Date.now() - task.lastAttempt;
  41. const timeSinceStart = Math.max(task.lastAttempt - task.timestamp, 1);
  42. const desiredDelay = Math.min(timeSinceStart * 1.2, maxDelay);
  43. return timeSinceLastAttempt >= desiredDelay;
  44. }
  45. /**
  46. * Checks if it is time to bail out based on the given timestamp.
  47. * @param {RetryTask} task The task to check.
  48. * @param {number} timeout The timeout for the queue.
  49. * @returns {boolean} true if it is time to bail, false otherwise.
  50. */
  51. function isTimeToBail(task, timeout) {
  52. return task.age > timeout;
  53. }
  54. /**
  55. * A class to represent a task in the retry queue.
  56. */
  57. class RetryTask {
  58. /**
  59. * The unique ID for the task.
  60. * @type {string}
  61. */
  62. id = Math.random().toString(36).slice(2);
  63. /**
  64. * The function to call.
  65. * @type {Function}
  66. */
  67. fn;
  68. /**
  69. * The error that was thrown.
  70. * @type {Error}
  71. */
  72. error;
  73. /**
  74. * The timestamp of the task.
  75. * @type {number}
  76. */
  77. timestamp = Date.now();
  78. /**
  79. * The timestamp of the last attempt.
  80. * @type {number}
  81. */
  82. lastAttempt = this.timestamp;
  83. /**
  84. * The resolve function for the promise.
  85. * @type {Function}
  86. */
  87. resolve;
  88. /**
  89. * The reject function for the promise.
  90. * @type {Function}
  91. */
  92. reject;
  93. /**
  94. * The AbortSignal to monitor for cancellation.
  95. * @type {AbortSignal|undefined}
  96. */
  97. signal;
  98. /**
  99. * Creates a new instance.
  100. * @param {Function} fn The function to call.
  101. * @param {Error} error The error that was thrown.
  102. * @param {Function} resolve The resolve function for the promise.
  103. * @param {Function} reject The reject function for the promise.
  104. * @param {AbortSignal|undefined} signal The AbortSignal to monitor for cancellation.
  105. */
  106. constructor(fn, error, resolve, reject, signal) {
  107. this.fn = fn;
  108. this.error = error;
  109. this.timestamp = Date.now();
  110. this.lastAttempt = Date.now();
  111. this.resolve = resolve;
  112. this.reject = reject;
  113. this.signal = signal;
  114. }
  115. /**
  116. * Gets the age of the task.
  117. * @returns {number} The age of the task in milliseconds.
  118. * @readonly
  119. */
  120. get age() {
  121. return Date.now() - this.timestamp;
  122. }
  123. }
  124. //-----------------------------------------------------------------------------
  125. // Exports
  126. //-----------------------------------------------------------------------------
  127. /**
  128. * A class that manages a queue of retry jobs.
  129. */
  130. class Retrier {
  131. /**
  132. * Represents the queue for processing tasks.
  133. * @type {Array<RetryTask>}
  134. */
  135. #queue = [];
  136. /**
  137. * The timeout for the queue.
  138. * @type {number}
  139. */
  140. #timeout;
  141. /**
  142. * The maximum delay for the queue.
  143. * @type {number}
  144. */
  145. #maxDelay;
  146. /**
  147. * The setTimeout() timer ID.
  148. * @type {NodeJS.Timeout|undefined}
  149. */
  150. #timerId;
  151. /**
  152. * The function to call.
  153. * @type {Function}
  154. */
  155. #check;
  156. /**
  157. * Creates a new instance.
  158. * @param {Function} check The function to call.
  159. * @param {object} [options] The options for the instance.
  160. * @param {number} [options.timeout] The timeout for the queue.
  161. * @param {number} [options.maxDelay] The maximum delay for the queue.
  162. */
  163. constructor(check, { timeout = MAX_TASK_TIMEOUT, maxDelay = MAX_TASK_DELAY } = {}) {
  164. if (typeof check !== "function") {
  165. throw new Error("Missing function to check errors");
  166. }
  167. this.#check = check;
  168. this.#timeout = timeout;
  169. this.#maxDelay = maxDelay;
  170. }
  171. /**
  172. * Adds a new retry job to the queue.
  173. * @param {Function} fn The function to call.
  174. * @param {object} [options] The options for the job.
  175. * @param {AbortSignal} [options.signal] The AbortSignal to monitor for cancellation.
  176. * @returns {Promise<any>} A promise that resolves when the queue is
  177. * processed.
  178. */
  179. retry(fn, { signal } = {}) {
  180. signal?.throwIfAborted();
  181. let result;
  182. try {
  183. result = fn();
  184. } catch (/** @type {any} */ error) {
  185. return Promise.reject(new Error(`Synchronous error: ${error.message}`, { cause: error }));
  186. }
  187. // if the result is not a promise then reject an error
  188. if (!result || typeof result.then !== "function") {
  189. return Promise.reject(new Error("Result is not a promise."));
  190. }
  191. // call the original function and catch any ENFILE or EMFILE errors
  192. // @ts-ignore because we know it's any
  193. return Promise.resolve(result).catch(error => {
  194. if (!this.#check(error)) {
  195. throw error;
  196. }
  197. return new Promise((resolve, reject) => {
  198. this.#queue.push(new RetryTask(fn, error, resolve, reject, signal));
  199. signal?.addEventListener("abort", () => {
  200. reject(signal.reason);
  201. });
  202. this.#processQueue();
  203. });
  204. });
  205. }
  206. /**
  207. * Processes the queue.
  208. * @returns {void}
  209. */
  210. #processQueue() {
  211. // clear any timer because we're going to check right now
  212. clearTimeout(this.#timerId);
  213. this.#timerId = undefined;
  214. // if there's nothing in the queue, we're done
  215. const task = this.#queue.shift();
  216. if (!task) {
  217. return;
  218. }
  219. const processAgain = () => {
  220. this.#timerId = setTimeout(() => this.#processQueue(), 0);
  221. };
  222. // if it's time to bail, then bail
  223. if (isTimeToBail(task, this.#timeout)) {
  224. task.reject(task.error);
  225. processAgain();
  226. return;
  227. }
  228. // if it's not time to retry, then wait and try again
  229. if (!isTimeToRetry(task, this.#maxDelay)) {
  230. this.#queue.push(task);
  231. processAgain();
  232. return;
  233. }
  234. // otherwise, try again
  235. task.lastAttempt = Date.now();
  236. // Promise.resolve needed in case it's a thenable but not a Promise
  237. Promise.resolve(task.fn())
  238. // @ts-ignore because we know it's any
  239. .then(result => task.resolve(result))
  240. // @ts-ignore because we know it's any
  241. .catch(error => {
  242. if (!this.#check(error)) {
  243. task.reject(error);
  244. return;
  245. }
  246. // update the task timestamp and push to back of queue to try again
  247. task.lastAttempt = Date.now();
  248. this.#queue.push(task);
  249. })
  250. .finally(() => this.#processQueue());
  251. }
  252. }
  253. exports.Retrier = Retrier;