fetch-retry.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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. import { isErrorLikeObject } from 'foxts/extract-error-message';
  22. if (!fs.existsSync(CACHE_DIR)) {
  23. fs.mkdirSync(CACHE_DIR, { recursive: true });
  24. }
  25. /**
  26. * Origins like filters.adtidy.org (Qrator CDN) send Last-Modified/ETag with no
  27. * Cache-Control/Expires, plus a large edge-dependent Age header and the ORIGINAL
  28. * Date header of the stored edge copy (hours in the past). undici refuses to
  29. * store a response whose Age exceeds its freshness lifetime (which, absent
  30. * explicit caching headers, is the tiny last-modified heuristic — 10% of time
  31. * since last modified), and separately drops any response already stale relative
  32. * to its Date header — so those assets never enter the cache and can never be
  33. * revalidated with HTTP 304. Dropping Age and Date makes undici treat the
  34. * response as cached-at-receipt; the content is at most Age + freshness stale
  35. * once, and the etag revalidation this enables is what we actually care about.
  36. */
  37. const stripCdnStalenessHeaders: Dispatcher.DispatcherComposeInterceptor = dispatch => (opts, handler) => dispatch(opts, {
  38. onRequestStart: (...args) => handler.onRequestStart?.(...args),
  39. onRequestUpgrade: (...args) => handler.onRequestUpgrade?.(...args),
  40. onResponseStart(controller, statusCode, headers, statusMessage) {
  41. delete headers.age;
  42. delete headers.date;
  43. return handler.onResponseStart?.(controller, statusCode, headers, statusMessage);
  44. },
  45. onResponseData: (...args) => handler.onResponseData?.(...args),
  46. onResponseEnd: (...args) => handler.onResponseEnd?.(...args),
  47. onResponseError: (...args) => handler.onResponseError?.(...args)
  48. });
  49. const agent = new Agent({
  50. allowH2: false
  51. }).compose(
  52. interceptors.dns({
  53. // disable IPv6
  54. dualStack: false,
  55. affinity: 4
  56. // TODO: proper cacheable-lookup, or even DoH
  57. }),
  58. interceptors.retry({
  59. maxRetries: 5,
  60. minTimeout: 500, // The initial retry delay in milliseconds
  61. maxTimeout: 10 * 1000, // The maximum retry delay in milliseconds
  62. // Undici still uses `statusCodes` as the first gate for HTTP response retries.
  63. // Our custom `retry()` callback only runs after a response status is admitted here,
  64. // so we must list our status codes here before we can read it in our retry callback.
  65. statusCodes: [404, 429, 500, 502, 503, 504],
  66. // TODO: this part of code is only for allow more errors to be retried by default
  67. // This should be removed once https://github.com/nodejs/undici/issues/3728 is implemented
  68. retry(err, { state, opts }, cb) {
  69. const errorCode = 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
  70. Object.defineProperty(err, '_url', {
  71. value: opts.method + ' ' + opts.origin?.toString() + opts.path
  72. });
  73. // Any code that is not a Undici's originated and allowed to retry
  74. if (
  75. errorCode === 'ERR_UNESCAPED_CHARACTERS'
  76. || errorCode === 'UND_ERR_DESTROYED'
  77. || err.message === 'Request path contains unescaped characters'
  78. || err.name === 'AbortError'
  79. ) {
  80. return cb(err);
  81. }
  82. const statusCode = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : null;
  83. // bail out if the status code matches one of the following
  84. if (
  85. statusCode != null
  86. && (
  87. statusCode === 401 // Unauthorized, should check credentials instead of retrying
  88. || statusCode === 403 // Forbidden, should check permissions instead of retrying
  89. // || statusCode === 404 // Not Found, should check URL instead of retrying
  90. || statusCode === 405 // Method Not Allowed, should check method instead of retrying
  91. )
  92. ) {
  93. return cb(err);
  94. }
  95. const origin = opts.origin?.toString();
  96. if (statusCode === 404) {
  97. if (origin?.includes('cdn.jsdelivr.net')) {
  98. // continue retry anyway, jsDelivr has recently broken and return HTTP 404 for bad origin
  99. } else {
  100. return cb(err);
  101. }
  102. }
  103. // if (errorCode === 'UND_ERR_REQ_RETRY') {
  104. // return cb(err);
  105. // }
  106. const {
  107. maxRetries = 5,
  108. minTimeout = 500,
  109. maxTimeout = 10 * 1000,
  110. timeoutFactor = 2,
  111. methods = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']
  112. } = opts.retryOptions || {};
  113. // If we reached the max number of retries
  114. if (state.counter > maxRetries) {
  115. return cb(err);
  116. }
  117. // If a set of method are provided and the current method is not in the list
  118. if (Array.isArray(methods) && !methods.includes(opts.method)) {
  119. return cb(err);
  120. }
  121. const headers = ('headers' in err && typeof err.headers === 'object') ? err.headers : undefined;
  122. const retryAfterHeader = (headers as Record<string, string> | null | undefined)?.['retry-after'];
  123. let retryAfter = -1;
  124. if (retryAfterHeader) {
  125. retryAfter = Number(retryAfterHeader);
  126. retryAfter = Number.isNaN(retryAfter)
  127. ? calculateRetryAfterHeader(retryAfterHeader)
  128. : retryAfter * 1e3; // Retry-After is in seconds
  129. }
  130. const retryTimeout = retryAfter > 0
  131. ? Math.min(retryAfter, maxTimeout)
  132. : Math.min(minTimeout * (timeoutFactor ** (state.counter - 1)), maxTimeout);
  133. console.log('[fetch retry]', 'schedule retry', { statusCode, retryTimeout, errorCode, url: opts.origin });
  134. // eslint-disable-next-line sukka/prefer-timer-id -- won't leak
  135. setTimeout(() => cb(null), retryTimeout);
  136. }
  137. // errorCodes: ['UND_ERR_HEADERS_TIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'ETIMEDOUT']
  138. }),
  139. interceptors.redirect({
  140. maxRedirections: 5
  141. }),
  142. stripCdnStalenessHeaders,
  143. interceptors.cache({
  144. store: new BetterSqlite3CacheStore({
  145. loose: true,
  146. location: path.join(CACHE_DIR, 'undici-better-sqlite3-cache-store.db'),
  147. maxCount: 128,
  148. maxEntrySize: 1024 * 1024 * 100, // 100 MiB
  149. revalidationRetention: 7 * 24 * 60 * 60 * 1000 // 7 days
  150. }),
  151. cacheByDefault: 10 * 60 * 1000 // 10 minutes
  152. })
  153. );
  154. function calculateRetryAfterHeader(retryAfter: string) {
  155. const current = Date.now();
  156. return new Date(retryAfter).getTime() - current;
  157. }
  158. export class ResponseError<T extends UndiciResponseData | Response> extends Error {
  159. readonly code: number;
  160. readonly statusCode: number;
  161. readonly url: string;
  162. constructor(public readonly res: T, public readonly info: RequestInfo, ...args: any[]) {
  163. const statusCode = 'statusCode' in res ? res.statusCode : res.status;
  164. super('HTTP ' + statusCode + ' ' + args.map(_ => inspect(_)).join(' '));
  165. this.url = typeof info === 'string'
  166. ? info
  167. : ('url' in info
  168. ? info.url
  169. : info.href);
  170. // eslint-disable-next-line sukka/unicorn/custom-error-definition -- deliberatly use previous name
  171. this.name = this.constructor.name;
  172. this.res = res;
  173. this.code = statusCode;
  174. this.statusCode = statusCode;
  175. }
  176. }
  177. export const defaultRequestInit = {
  178. headers: {
  179. 'User-Agent': 'node-fetch'
  180. }
  181. };
  182. export async function $$fetch(url: RequestInfo, init: RequestInit = defaultRequestInit) {
  183. init.dispatcher = agent;
  184. try {
  185. const res = await undici.fetch(url, init);
  186. if (res.status >= 400) {
  187. throw new ResponseError(res, url);
  188. }
  189. if ((res.status < 200 || res.status > 299) && res.status !== 304) {
  190. throw new ResponseError(res, url);
  191. }
  192. return res;
  193. } catch (err: unknown) {
  194. if (isAbortErrorLike(err)) {
  195. console.log(picocolors.gray('[fetch abort]'), url);
  196. } else {
  197. console.log(picocolors.gray('[fetch fail]'), url, err);
  198. }
  199. throw err;
  200. }
  201. }
  202. export { $$fetch as '~fetch' };
  203. /**
  204. * dohdec constructs its own `Request` object for its `hooks` from `globalThis.Request`
  205. *
  206. * But we are using `undici.fetch` instead of `globalThis.fetch`, hence the version
  207. * mismatch.
  208. *
  209. * undici, on the other hand, use `instanceof Request` internally for narrowing, resulting
  210. * in it treats foreign `Request` objects as `URL` and try to parse them as URLs, causing
  211. * `TypeError: Failed to construct 'URL': [object Request]`
  212. *
  213. * See also https://github.com/nodejs/undici/issues/2155
  214. *
  215. * We already know that dohdec will only pass one `Request` object to `fetch` because
  216. * of its internal `hooks`:
  217. *
  218. * https://github.com/hildjj/dohdec/blob/d2f763db62d46f505d109be12bc697224cd42f93/pkg/dohdec/lib/doh.js#L291
  219. */
  220. export async function fetchForDoH(input: RequestInfo, _init?: RequestInit) {
  221. if (typeof input === 'object' && 'url' in input) {
  222. // Read body as ArrayBuffer before re-wrapping. The original body is a ReadableStream
  223. // from a foreign context (different undici instance / Node.js globals). Passing it
  224. // directly to new UndiciRequest fails undici's instanceof ReadableStream check and
  225. // silently drops the body. ArrayBuffer is a plain value with no cross-context issues,
  226. // and also allows the retry interceptor to re-send the body on retries.
  227. const body = input.body === null ? null : await input.arrayBuffer();
  228. input = new UndiciRequest(input.url, {
  229. method: input.method,
  230. mode: input.mode,
  231. credentials: input.credentials,
  232. cache: input.cache,
  233. redirect: input.redirect,
  234. integrity: input.integrity,
  235. keepalive: input.keepalive,
  236. signal: input.signal,
  237. headers: input.headers,
  238. body,
  239. referrer: '',
  240. referrerPolicy: 'no-referrer',
  241. dispatcher: agent
  242. });
  243. }
  244. // DoH servers may return a valid DNS wire format body with a non-200 status
  245. // (e.g. 503 with a DNS SERVFAIL). Let the DoH client parse the body and decide
  246. // — never throw on HTTP status here.
  247. return undici.fetch(input);
  248. }
  249. /** @deprecated -- undici.requests doesn't support gzip/br/deflate, and has difficulty w/ undidi cache */
  250. export async function requestWithLog(url: string, opt?: Parameters<typeof undici.request>[1]) {
  251. opt ??= {};
  252. opt.dispatcher = agent;
  253. try {
  254. const res = await undici.request(url, opt);
  255. if (res.statusCode >= 400) {
  256. throw new ResponseError(res, url);
  257. }
  258. if ((res.statusCode < 200 || res.statusCode > 299) && res.statusCode !== 304) {
  259. throw new ResponseError(res, url);
  260. }
  261. return res;
  262. } catch (err: unknown) {
  263. if (isAbortErrorLike(err)) {
  264. console.log(picocolors.gray('[fetch abort]'), url);
  265. } else {
  266. console.log(picocolors.gray('[fetch fail]'), url, { name: isErrorLikeObject(err) ? err.name : undefined }, err);
  267. }
  268. throw err;
  269. }
  270. }