fetch-retry.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import picocolors from 'picocolors';
  2. import undici, {
  3. interceptors,
  4. Agent,
  5. Request as UndiciRequest
  6. // setGlobalDispatcher
  7. } from 'undici';
  8. import type {
  9. Dispatcher,
  10. Response,
  11. RequestInit,
  12. RequestInfo
  13. } from 'undici';
  14. import { BetterSqlite3CacheStore } from 'undici-cache-store-better-sqlite3';
  15. export type UndiciResponseData<T = unknown> = Dispatcher.ResponseData<T>;
  16. import { inspect } from 'node:util';
  17. import path from 'node:path';
  18. import fs from 'node:fs';
  19. import { CACHE_DIR } from '../constants/dir';
  20. import { isAbortErrorLike } from 'foxts/abort-error';
  21. if (!fs.existsSync(CACHE_DIR)) {
  22. fs.mkdirSync(CACHE_DIR, { recursive: true });
  23. }
  24. const agent = new Agent({
  25. allowH2: true
  26. });
  27. agent.compose(
  28. interceptors.dns({
  29. // disable IPv6
  30. dualStack: false,
  31. affinity: 4
  32. // TODO: proper cacheable-lookup, or even DoH
  33. }),
  34. interceptors.retry({
  35. maxRetries: 5,
  36. minTimeout: 500, // The initial retry delay in milliseconds
  37. maxTimeout: 10 * 1000, // The maximum retry delay in milliseconds
  38. // TODO: this part of code is only for allow more errors to be retried by default
  39. // This should be removed once https://github.com/nodejs/undici/issues/3728 is implemented
  40. retry(err, { state, opts }, cb) {
  41. const errorCode = 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
  42. Object.defineProperty(err, '_url', {
  43. value: opts.method + ' ' + opts.origin?.toString() + opts.path
  44. });
  45. // Any code that is not a Undici's originated and allowed to retry
  46. if (
  47. errorCode === 'ERR_UNESCAPED_CHARACTERS'
  48. || err.message === 'Request path contains unescaped characters'
  49. || err.name === 'AbortError'
  50. ) {
  51. return cb(err);
  52. }
  53. const statusCode = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : null;
  54. // bail out if the status code matches one of the following
  55. if (
  56. statusCode != null
  57. && (
  58. statusCode === 401 // Unauthorized, should check credentials instead of retrying
  59. || statusCode === 403 // Forbidden, should check permissions instead of retrying
  60. || statusCode === 404 // Not Found, should check URL instead of retrying
  61. || statusCode === 405 // Method Not Allowed, should check method instead of retrying
  62. )
  63. ) {
  64. return cb(err);
  65. }
  66. // if (errorCode === 'UND_ERR_REQ_RETRY') {
  67. // return cb(err);
  68. // }
  69. const {
  70. maxRetries = 5,
  71. minTimeout = 500,
  72. maxTimeout = 10 * 1000,
  73. timeoutFactor = 2,
  74. methods = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']
  75. } = opts.retryOptions || {};
  76. // If we reached the max number of retries
  77. if (state.counter > maxRetries) {
  78. return cb(err);
  79. }
  80. // If a set of method are provided and the current method is not in the list
  81. if (Array.isArray(methods) && !methods.includes(opts.method)) {
  82. return cb(err);
  83. }
  84. const headers = ('headers' in err && typeof err.headers === 'object') ? err.headers : undefined;
  85. const retryAfterHeader = (headers as Record<string, string> | null | undefined)?.['retry-after'];
  86. let retryAfter = -1;
  87. if (retryAfterHeader) {
  88. retryAfter = Number(retryAfterHeader);
  89. retryAfter = Number.isNaN(retryAfter)
  90. ? calculateRetryAfterHeader(retryAfterHeader)
  91. : retryAfter * 1e3; // Retry-After is in seconds
  92. }
  93. const retryTimeout = retryAfter > 0
  94. ? Math.min(retryAfter, maxTimeout)
  95. : Math.min(minTimeout * (timeoutFactor ** (state.counter - 1)), maxTimeout);
  96. console.log('[fetch retry]', 'schedule retry', { statusCode, retryTimeout, errorCode, url: opts.origin });
  97. // eslint-disable-next-line sukka/prefer-timer-id -- won't leak
  98. setTimeout(() => cb(null), retryTimeout);
  99. }
  100. // errorCodes: ['UND_ERR_HEADERS_TIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'ETIMEDOUT']
  101. }),
  102. interceptors.redirect({
  103. maxRedirections: 5
  104. }),
  105. interceptors.cache({
  106. store: new BetterSqlite3CacheStore({
  107. loose: true,
  108. location: path.join(CACHE_DIR, 'undici-better-sqlite3-cache-store.db'),
  109. maxCount: 128,
  110. maxEntrySize: 1024 * 1024 * 100 // 100 MiB
  111. }),
  112. cacheByDefault: 600 // 10 minutes
  113. })
  114. );
  115. function calculateRetryAfterHeader(retryAfter: string) {
  116. const current = Date.now();
  117. return new Date(retryAfter).getTime() - current;
  118. }
  119. export class ResponseError<T extends UndiciResponseData | Response> extends Error {
  120. readonly code: number;
  121. readonly statusCode: number;
  122. readonly url: string;
  123. constructor(public readonly res: T, public readonly info: RequestInfo, ...args: any[]) {
  124. const statusCode = 'statusCode' in res ? res.statusCode : res.status;
  125. super('HTTP ' + statusCode + ' ' + args.map(_ => inspect(_)).join(' '));
  126. this.url = typeof info === 'string'
  127. ? info
  128. : ('url' in info
  129. ? info.url
  130. : info.href);
  131. // eslint-disable-next-line sukka/unicorn/custom-error-definition -- deliberatly use previous name
  132. this.name = this.constructor.name;
  133. this.res = res;
  134. this.code = statusCode;
  135. this.statusCode = statusCode;
  136. }
  137. }
  138. export const defaultRequestInit = {
  139. headers: {
  140. 'User-Agent': 'node-fetch'
  141. }
  142. };
  143. export async function $$fetch(url: RequestInfo, init: RequestInit = defaultRequestInit) {
  144. init.dispatcher = agent;
  145. try {
  146. const res = await undici.fetch(url, init);
  147. if (res.status >= 400) {
  148. throw new ResponseError(res, url);
  149. }
  150. if ((res.status < 200 || res.status > 299) && res.status !== 304) {
  151. throw new ResponseError(res, url);
  152. }
  153. return res;
  154. } catch (err: unknown) {
  155. if (isAbortErrorLike(err)) {
  156. console.log(picocolors.gray('[fetch abort]'), url);
  157. } else {
  158. console.log(picocolors.gray('[fetch fail]'), url, err);
  159. }
  160. throw err;
  161. }
  162. }
  163. export { $$fetch as '~fetch' };
  164. /**
  165. * A fetch wrapper for DoH (DNS-over-HTTPS) usage where the input may be a
  166. * `Request` object created by a different undici instance or Node.js globals.
  167. * Without normalisation, undici's internal `instanceof Request` check fails and
  168. * it tries to parse `[object Request]` as a URL.
  169. * See https://github.com/nodejs/undici/issues/2155
  170. */
  171. export async function fetchForDoH(input: RequestInfo, init?: RequestInit) {
  172. if (typeof input === 'object' && 'url' in input) {
  173. // Normalise the foreign Request into a proper undici Request, preserving all
  174. // of its properties. init is passed separately so undici merges them itself,
  175. // exactly as real fetch(request, init) would — no manual header handling needed.
  176. input = new UndiciRequest(input.url, {
  177. ...input,
  178. // force no-referrer to avoid about:client
  179. referrerPolicy: 'no-referrer',
  180. referrer: ''
  181. });
  182. }
  183. return $$fetch(input, init);
  184. }
  185. /** @deprecated -- undici.requests doesn't support gzip/br/deflate, and has difficulty w/ undidi cache */
  186. export async function requestWithLog(url: string, opt?: Parameters<typeof undici.request>[1]) {
  187. opt ??= {};
  188. opt.dispatcher = agent;
  189. try {
  190. const res = await undici.request(url, opt);
  191. if (res.statusCode >= 400) {
  192. throw new ResponseError(res, url);
  193. }
  194. if ((res.statusCode < 200 || res.statusCode > 299) && res.statusCode !== 304) {
  195. throw new ResponseError(res, url);
  196. }
  197. return res;
  198. } catch (err: unknown) {
  199. if (isAbortErrorLike(err)) {
  200. console.log(picocolors.gray('[fetch abort]'), url);
  201. } else {
  202. console.log(picocolors.gray('[fetch fail]'), url, { name: err instanceof Error ? err.name : undefined }, err);
  203. }
  204. throw err;
  205. }
  206. }