fetch-retry.ts 5.1 KB

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