async_caller.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import pRetry from "p-retry";
  2. import PQueueMod from "p-queue";
  3. import { _getFetchImplementation } from "../singletons/fetch.js";
  4. const STATUS_NO_RETRY = [
  5. 400, // Bad Request
  6. 401, // Unauthorized
  7. 403, // Forbidden
  8. 404, // Not Found
  9. 405, // Method Not Allowed
  10. 406, // Not Acceptable
  11. 407, // Proxy Authentication Required
  12. 408, // Request Timeout
  13. ];
  14. const STATUS_IGNORE = [
  15. 409, // Conflict
  16. ];
  17. /**
  18. * A class that can be used to make async calls with concurrency and retry logic.
  19. *
  20. * This is useful for making calls to any kind of "expensive" external resource,
  21. * be it because it's rate-limited, subject to network issues, etc.
  22. *
  23. * Concurrent calls are limited by the `maxConcurrency` parameter, which defaults
  24. * to `Infinity`. This means that by default, all calls will be made in parallel.
  25. *
  26. * Retries are limited by the `maxRetries` parameter, which defaults to 6. This
  27. * means that by default, each call will be retried up to 6 times, with an
  28. * exponential backoff between each attempt.
  29. */
  30. export class AsyncCaller {
  31. constructor(params) {
  32. Object.defineProperty(this, "maxConcurrency", {
  33. enumerable: true,
  34. configurable: true,
  35. writable: true,
  36. value: void 0
  37. });
  38. Object.defineProperty(this, "maxRetries", {
  39. enumerable: true,
  40. configurable: true,
  41. writable: true,
  42. value: void 0
  43. });
  44. Object.defineProperty(this, "queue", {
  45. enumerable: true,
  46. configurable: true,
  47. writable: true,
  48. value: void 0
  49. });
  50. Object.defineProperty(this, "onFailedResponseHook", {
  51. enumerable: true,
  52. configurable: true,
  53. writable: true,
  54. value: void 0
  55. });
  56. Object.defineProperty(this, "debug", {
  57. enumerable: true,
  58. configurable: true,
  59. writable: true,
  60. value: void 0
  61. });
  62. this.maxConcurrency = params.maxConcurrency ?? Infinity;
  63. this.maxRetries = params.maxRetries ?? 6;
  64. this.debug = params.debug;
  65. if ("default" in PQueueMod) {
  66. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  67. this.queue = new PQueueMod.default({
  68. concurrency: this.maxConcurrency,
  69. });
  70. }
  71. else {
  72. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  73. this.queue = new PQueueMod({ concurrency: this.maxConcurrency });
  74. }
  75. this.onFailedResponseHook = params?.onFailedResponseHook;
  76. }
  77. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  78. call(callable, ...args) {
  79. const onFailedResponseHook = this.onFailedResponseHook;
  80. return this.queue.add(() => pRetry(() => callable(...args).catch((error) => {
  81. // eslint-disable-next-line no-instanceof/no-instanceof
  82. if (error instanceof Error) {
  83. throw error;
  84. }
  85. else {
  86. throw new Error(error);
  87. }
  88. }), {
  89. async onFailedAttempt(error) {
  90. if (error.message.startsWith("Cancel") ||
  91. error.message.startsWith("TimeoutError") ||
  92. error.message.startsWith("AbortError")) {
  93. throw error;
  94. }
  95. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  96. if (error?.code === "ECONNABORTED") {
  97. throw error;
  98. }
  99. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  100. const response = error?.response;
  101. const status = response?.status;
  102. if (status) {
  103. if (STATUS_NO_RETRY.includes(+status)) {
  104. throw error;
  105. }
  106. else if (STATUS_IGNORE.includes(+status)) {
  107. return;
  108. }
  109. if (onFailedResponseHook) {
  110. await onFailedResponseHook(response);
  111. }
  112. }
  113. },
  114. // If needed we can change some of the defaults here,
  115. // but they're quite sensible.
  116. retries: this.maxRetries,
  117. randomize: true,
  118. }), { throwOnTimeout: true });
  119. }
  120. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  121. callWithOptions(options, callable, ...args) {
  122. // Note this doesn't cancel the underlying request,
  123. // when available prefer to use the signal option of the underlying call
  124. if (options.signal) {
  125. return Promise.race([
  126. this.call(callable, ...args),
  127. new Promise((_, reject) => {
  128. options.signal?.addEventListener("abort", () => {
  129. reject(new Error("AbortError"));
  130. });
  131. }),
  132. ]);
  133. }
  134. return this.call(callable, ...args);
  135. }
  136. fetch(...args) {
  137. return this.call(() => _getFetchImplementation(this.debug)(...args).then((res) => res.ok ? res : Promise.reject(res)));
  138. }
  139. }