fetch-retry.ts 6.8 KB

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