fetch-retry.ts 6.4 KB

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