fetch-retry.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import picocolors from 'picocolors';
  2. import undici, {
  3. interceptors,
  4. EnvHttpProxyAgent,
  5. setGlobalDispatcher
  6. } from 'undici';
  7. import type {
  8. Dispatcher
  9. } from 'undici';
  10. export type UndiciResponseData<T = any> = Dispatcher.ResponseData<T>;
  11. import CacheableLookup from 'cacheable-lookup';
  12. import type { LookupOptions as CacheableLookupOptions } from 'cacheable-lookup';
  13. const cacheableLookup = new CacheableLookup();
  14. const agent = new EnvHttpProxyAgent({
  15. // allowH2: true,
  16. connect: {
  17. lookup(hostname, opt, cb) {
  18. return cacheableLookup.lookup(hostname, opt as CacheableLookupOptions, cb);
  19. }
  20. }
  21. });
  22. setGlobalDispatcher(agent.compose(
  23. interceptors.retry({
  24. maxRetries: 5,
  25. minTimeout: 10000,
  26. // TODO: this part of code is only for allow more errors to be retried by default
  27. // This should be removed once https://github.com/nodejs/undici/issues/3728 is implemented
  28. // @ts-expect-error -- retry return type should be void
  29. retry(err, { state, opts }, cb) {
  30. const statusCode = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : null;
  31. const errorCode = 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
  32. const headers = ('headers' in err && typeof err.headers === 'object') ? err.headers : undefined;
  33. const { counter } = state;
  34. // Any code that is not a Undici's originated and allowed to retry
  35. if (
  36. errorCode === 'ERR_UNESCAPED_CHARACTERS'
  37. || err.message === 'Request path contains unescaped characters'
  38. || err.name === 'AbortError'
  39. ) {
  40. return cb(err);
  41. }
  42. if (errorCode !== 'UND_ERR_REQ_RETRY') {
  43. return cb(err);
  44. }
  45. const { method, retryOptions = {} } = opts;
  46. const {
  47. maxRetries = 5,
  48. minTimeout = 500,
  49. maxTimeout = 30 * 1000,
  50. timeoutFactor = 2,
  51. methods = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']
  52. } = retryOptions;
  53. // If we reached the max number of retries
  54. if (counter > maxRetries) {
  55. return cb(err);
  56. }
  57. // If a set of method are provided and the current method is not in the list
  58. if (Array.isArray(methods) && !methods.includes(method)) {
  59. return cb(err);
  60. }
  61. // bail out if the status code matches one of the following
  62. if (
  63. statusCode != null
  64. && (
  65. statusCode === 401 // Unauthorized, should check credentials instead of retrying
  66. || statusCode === 403 // Forbidden, should check permissions instead of retrying
  67. || statusCode === 404 // Not Found, should check URL instead of retrying
  68. || statusCode === 405 // Method Not Allowed, should check method instead of retrying
  69. )
  70. ) {
  71. return cb(err);
  72. }
  73. const retryAfterHeader = (headers as Record<string, string> | null | undefined)?.['retry-after'];
  74. let retryAfter = -1;
  75. if (retryAfterHeader) {
  76. retryAfter = Number(retryAfterHeader);
  77. retryAfter = Number.isNaN(retryAfter)
  78. ? calculateRetryAfterHeader(retryAfterHeader)
  79. : retryAfter * 1e3; // Retry-After is in seconds
  80. }
  81. const retryTimeout
  82. = retryAfter > 0
  83. ? Math.min(retryAfter, maxTimeout)
  84. : Math.min(minTimeout * (timeoutFactor ** (counter - 1)), maxTimeout);
  85. // eslint-disable-next-line sukka/prefer-timer-id -- won't leak
  86. setTimeout(() => cb(null), retryTimeout);
  87. }
  88. // errorCodes: ['UND_ERR_HEADERS_TIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'ETIMEDOUT']
  89. }),
  90. interceptors.redirect({
  91. maxRedirections: 5
  92. })
  93. ));
  94. function calculateRetryAfterHeader(retryAfter: string) {
  95. const current = Date.now();
  96. return new Date(retryAfter).getTime() - current;
  97. }
  98. export class UndiciResponseError extends Error {
  99. readonly code: number;
  100. readonly statusCode: number;
  101. constructor(public readonly res: UndiciResponseData, public readonly url: string) {
  102. super('HTTP ' + res.statusCode);
  103. if ('captureStackTrace' in Error) {
  104. Error.captureStackTrace(this, UndiciResponseError);
  105. }
  106. // eslint-disable-next-line sukka/unicorn/custom-error-definition -- deliberatly use previous name
  107. this.name = this.constructor.name;
  108. this.res = res;
  109. this.code = res.statusCode;
  110. this.statusCode = res.statusCode;
  111. }
  112. }
  113. export const defaultRequestInit = {
  114. headers: {
  115. 'User-Agent': 'curl/8.9.1 (https://github.com/SukkaW/Surge)'
  116. }
  117. };
  118. export async function requestWithLog(url: string, opt?: Parameters<typeof undici.request>[1]) {
  119. try {
  120. const res = await undici.request(url, opt);
  121. if (res.statusCode >= 400) {
  122. throw new UndiciResponseError(res, url);
  123. }
  124. if (!(res.statusCode >= 200 && res.statusCode <= 299) && res.statusCode !== 304) {
  125. throw new UndiciResponseError(res, url);
  126. }
  127. return res;
  128. } catch (err: unknown) {
  129. if (typeof err === 'object' && err !== null && 'name' in err) {
  130. if ((
  131. err.name === 'AbortError'
  132. || ('digest' in err && err.digest === 'AbortError')
  133. )) {
  134. console.log(picocolors.gray('[fetch abort]'), url);
  135. }
  136. } else {
  137. console.log(picocolors.gray('[fetch fail]'), url, { name: (err as any).name }, err);
  138. }
  139. throw err;
  140. }
  141. }