retrier.mjs 8.3 KB

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