Refactor: use retry from undici

This commit is contained in:
SukkaW
2024-10-13 15:43:33 +08:00
parent 2695de7410
commit 79da4e18fc
7 changed files with 151 additions and 201 deletions

View File

@@ -6,7 +6,7 @@ import { task } from './trace';
import { extract as tarExtract } from 'tar-fs';
import type { Headers as TarEntryHeaders } from 'tar-fs';
import zlib from 'node:zlib';
import { fetchWithRetry } from './lib/fetch-retry';
import { fetchWithLog } from './lib/fetch-retry';
import { Readable } from 'node:stream';
const GITHUB_CODELOAD_URL = 'https://codeload.github.com/sukkalab/ruleset.skk.moe/tar.gz/master';
@@ -21,7 +21,7 @@ export const downloadPreviousBuild = task(require.main === module, __filename)(a
}
const tarGzUrl = await span.traceChildAsync('get tar.gz url', async () => {
const resp = await fetchWithRetry(GITHUB_CODELOAD_URL, { method: 'HEAD' });
const resp = await fetchWithLog(GITHUB_CODELOAD_URL, { method: 'HEAD' });
if (resp.status !== 200) {
console.warn('Download previous build from GitHub failed! Status:', resp.status);
console.warn('Switch to GitLab');
@@ -31,7 +31,7 @@ export const downloadPreviousBuild = task(require.main === module, __filename)(a
});
return span.traceChildAsync('download & extract previoud build', async () => {
const resp = await fetchWithRetry(tarGzUrl, {
const resp = await fetchWithLog(tarGzUrl, {
headers: {
'User-Agent': 'curl/8.9.1',
// https://github.com/unjs/giget/issues/97

View File

@@ -8,9 +8,11 @@ import { fastStringArrayJoin, identity, mergeHeaders } from './misc';
import { performance } from 'node:perf_hooks';
import fs from 'node:fs';
import { stringHash } from './string-hash';
import { defaultRequestInit, fetchWithRetry } from './fetch-retry';
import { defaultRequestInit, fetchWithLog } from './fetch-retry';
import { Custom304NotModifiedError, CustomAbortError, CustomNoETagFallbackError, fetchAssets, sleepWithAbort } from './fetch-assets';
import type { Response, RequestInit } from 'undici';
const enum CacheStatus {
Hit = 'hit',
Stale = 'stale',
@@ -216,7 +218,7 @@ export class Cache<S = string> {
requestInit?: RequestInit
): Promise<T> {
if (opt.temporaryBypass) {
return fn(await fetchWithRetry(url, requestInit ?? defaultRequestInit));
return fn(await fetchWithLog(url, requestInit));
}
const baseKey = url + '$' + extraCacheKey;
@@ -255,10 +257,10 @@ export class Cache<S = string> {
const cached = this.get(cachedKey);
if (cached == null) {
return onMiss(await fetchWithRetry(url, requestInit ?? defaultRequestInit));
return onMiss(await fetchWithLog(url, requestInit));
}
const resp = await fetchWithRetry(
const resp = await fetchWithLog(
url,
{
...(requestInit ?? defaultRequestInit),
@@ -321,7 +323,7 @@ export class Cache<S = string> {
}
const etag = this.get(getETagKey(url));
const res = await fetchWithRetry(
const res = await fetchWithLog(
url,
{
signal: controller.signal,

View File

@@ -1,5 +1,5 @@
import picocolors from 'picocolors';
import { defaultRequestInit, fetchWithRetry } from './fetch-retry';
import { defaultRequestInit, fetchWithLog } from './fetch-retry';
import { setTimeout } from 'node:timers/promises';
// eslint-disable-next-line sukka/unicorn/custom-error-definition -- typescript is better
@@ -59,7 +59,7 @@ export async function fetchAssets(url: string, fallbackUrls: string[] | readonly
console.log(picocolors.gray('[fetch cancelled]'), picocolors.gray(url));
throw new CustomAbortError();
}
const res = await fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit });
const res = await fetchWithLog(url, { signal: controller.signal, ...defaultRequestInit });
const text = await res.text();
controller.abort();
return text;

View File

@@ -1,14 +1,12 @@
import retry from 'async-retry';
import picocolors from 'picocolors';
import { setTimeout } from 'node:timers/promises';
import {
fetch as _fetch,
fetch,
interceptors,
EnvHttpProxyAgent,
setGlobalDispatcher
} from 'undici';
import type { Request, Response, RequestInit } from 'undici';
import type { Response, RequestInit, RequestInfo } from 'undici';
import CacheableLookup from 'cacheable-lookup';
import type { LookupOptions as CacheableLookupOptions } from 'cacheable-lookup';
@@ -16,7 +14,7 @@ import type { LookupOptions as CacheableLookupOptions } from 'cacheable-lookup';
const cacheableLookup = new CacheableLookup();
const agent = new EnvHttpProxyAgent({
allowH2: true,
// allowH2: true,
connect: {
lookup(hostname, opt, cb) {
return cacheableLookup.lookup(hostname, opt as CacheableLookupOptions, cb);
@@ -28,21 +26,89 @@ setGlobalDispatcher(agent.compose(
interceptors.retry({
maxRetries: 5,
minTimeout: 10000,
errorCodes: ['UND_ERR_HEADERS_TIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'ETIMEDOUT']
// TODO: this part of code is only for allow more errors to be retried by default
// This should be removed once https://github.com/nodejs/undici/issues/3728 is implemented
// @ts-expect-error -- retry return type should be void
retry(err, { state, opts }, cb) {
const statusCode = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : null;
const errorCode = 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
const headers = ('headers' in err && typeof err.headers === 'object') ? err.headers : undefined;
const { counter } = state;
// Any code that is not a Undici's originated and allowed to retry
if (
errorCode === 'ERR_UNESCAPED_CHARACTERS'
|| err.message === 'Request path contains unescaped characters'
|| err.name === 'AbortError'
) {
return cb(err);
}
if (errorCode !== 'UND_ERR_REQ_RETRY') {
return cb(err);
}
const { method, retryOptions = {} } = opts;
const {
maxRetries = 5,
minTimeout = 500,
maxTimeout = 30 * 1000,
timeoutFactor = 2,
methods = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']
} = retryOptions;
// If we reached the max number of retries
if (counter > maxRetries) {
return cb(err);
}
// If a set of method are provided and the current method is not in the list
if (Array.isArray(methods) && !methods.includes(method)) {
return cb(err);
}
// bail out if the status code matches one of the following
if (
statusCode != null
&& (
statusCode === 401 // Unauthorized, should check credentials instead of retrying
|| statusCode === 403 // Forbidden, should check permissions instead of retrying
|| statusCode === 404 // Not Found, should check URL instead of retrying
|| statusCode === 405 // Method Not Allowed, should check method instead of retrying
)
) {
return cb(err);
}
const retryAfterHeader = (headers as Record<string, string> | null | undefined)?.['retry-after'];
let retryAfter = -1;
if (retryAfterHeader) {
retryAfter = Number(retryAfterHeader);
retryAfter = Number.isNaN(retryAfter)
? calculateRetryAfterHeader(retryAfterHeader)
: retryAfter * 1e3; // Retry-After is in seconds
}
const retryTimeout
= retryAfter > 0
? Math.min(retryAfter, maxTimeout)
: Math.min(minTimeout * (timeoutFactor ** (counter - 1)), maxTimeout);
// eslint-disable-next-line sukka/prefer-timer-id -- won't leak
setTimeout(() => cb(null), retryTimeout);
}
// errorCodes: ['UND_ERR_HEADERS_TIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN', 'ENETUNREACH', 'EHOSTDOWN', 'EHOSTUNREACH', 'EPIPE', 'ETIMEDOUT']
}),
interceptors.redirect({
maxRedirections: 5
})
));
function isClientError(err: unknown): err is NodeJS.ErrnoException {
if (!err || typeof err !== 'object') return false;
if ('code' in err) return err.code === 'ERR_UNESCAPED_CHARACTERS';
if ('message' in err) return err.message === 'Request path contains unescaped characters';
if ('name' in err) return err.name === 'AbortError';
return false;
function calculateRetryAfterHeader(retryAfter: string) {
const current = Date.now();
return new Date(retryAfter).getTime() - current;
}
export class ResponseError extends Error {
@@ -67,129 +133,38 @@ export class ResponseError extends Error {
}
}
interface FetchRetryOpt {
minTimeout?: number,
retries?: number,
factor?: number,
maxRetryAfter?: number,
// onRetry?: (err: Error) => void,
retryOnNon2xx?: boolean,
retryOn404?: boolean
}
interface FetchWithRetry {
(url: string | URL | Request, opts?: RequestInit & { retry?: FetchRetryOpt }): Promise<Response>
}
const DEFAULT_OPT: Required<FetchRetryOpt> = {
// timeouts will be [10, 60, 360, 2160, 12960]
// (before randomization is added)
minTimeout: 10,
retries: 5,
factor: 6,
maxRetryAfter: 20,
retryOnNon2xx: true,
retryOn404: false
};
function createFetchRetry(fetch: typeof _fetch): FetchWithRetry {
const fetchRetry: FetchWithRetry = async (url, opts = {}) => {
const retryOpts = Object.assign(
DEFAULT_OPT,
opts.retry
);
try {
return await retry(async (bail) => {
try {
// this will be retried
const res = (await fetch(url, opts));
if ((res.status >= 500 && res.status < 600) || res.status === 429) {
// NOTE: doesn't support http-date format
const retryAfterHeader = res.headers.get('retry-after');
if (retryAfterHeader) {
const retryAfter = Number.parseInt(retryAfterHeader, 10);
if (retryAfter) {
if (retryAfter > retryOpts.maxRetryAfter) {
return res;
}
await setTimeout(retryAfter * 1e3, undefined, { ref: false });
}
}
throw new ResponseError(res);
} else {
if ((!res.ok && res.status !== 304) && retryOpts.retryOnNon2xx) {
throw new ResponseError(res);
}
return res;
}
} catch (err: unknown) {
if (mayBailError(err)) {
return bail(err) as never;
};
if (err instanceof AggregateError) {
for (const e of err.errors) {
if (mayBailError(e)) {
// bail original error
return bail(err) as never;
};
}
}
console.log(picocolors.gray('[fetch fail]'), url, { name: (err as any).name }, err);
// Do not retry on 404
if (err instanceof ResponseError && err.res.status === 404) {
return bail(err) as never;
}
const newErr = new Error('Fetch failed');
newErr.cause = err;
throw newErr;
}
}, retryOpts);
function mayBailError(err: unknown) {
if (typeof err === 'object' && err !== null && 'name' in err) {
if ((
err.name === 'AbortError'
|| ('digest' in err && err.digest === 'AbortError')
)) {
console.log(picocolors.gray('[fetch abort]'), url);
return true;
}
if (err.name === 'Custom304NotModifiedError') {
return true;
}
if (err.name === 'CustomNoETagFallbackError') {
return true;
}
}
return !!(isClientError(err));
};
} catch (err) {
if (err instanceof ResponseError) {
return err.res;
}
throw err;
}
};
for (const k of Object.keys(_fetch)) {
const key = k as keyof typeof _fetch;
fetchRetry[key] = _fetch[key];
}
return fetchRetry;
}
export const defaultRequestInit: RequestInit = {
headers: {
'User-Agent': 'curl/8.9.1 (https://github.com/SukkaW/Surge)'
}
};
export const fetchWithRetry = createFetchRetry(_fetch as any);
export async function fetchWithLog(url: RequestInfo, opts: RequestInit = defaultRequestInit) {
try {
// this will be retried
const res = (await fetch(url, opts));
if (res.status >= 400) {
throw new ResponseError(res);
}
if (!res.ok && res.status !== 304) {
throw new ResponseError(res);
}
return res;
} catch (err: unknown) {
if (typeof err === 'object' && err !== null && 'name' in err) {
if ((
err.name === 'AbortError'
|| ('digest' in err && err.digest === 'AbortError')
)) {
console.log(picocolors.gray('[fetch abort]'), url);
}
} else {
console.log(picocolors.gray('[fetch fail]'), url, { name: (err as any).name }, err);
}
throw err;
}
};

View File

@@ -2,6 +2,7 @@ import path, { dirname } from 'node:path';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { OUTPUT_CLASH_DIR, OUTPUT_SINGBOX_DIR, OUTPUT_SURGE_DIR } from '../constants/dir';
import type { HeadersInit } from 'undici';
export const isTruthy = <T>(i: T | 0 | '' | false | null | undefined): i is T => !!i;
@@ -102,7 +103,7 @@ export function withBannerArray(title: string, description: string[] | readonly
];
};
export function mergeHeaders(headersA: RequestInit['headers'] | undefined, headersB: RequestInit['headers']) {
export function mergeHeaders<T extends RequestInit['headers'] | HeadersInit>(headersA: T | undefined, headersB: T): T {
if (headersA == null) {
return headersB;
}
@@ -111,20 +112,20 @@ export function mergeHeaders(headersA: RequestInit['headers'] | undefined, heade
throw new TypeError('Array headers is not supported');
}
const result = new Headers(headersA);
const result = new Headers(headersA as any);
if (headersB instanceof Headers) {
headersB.forEach((value, key) => {
result.set(key, value);
});
return result;
return result as T;
}
for (const key in headersB) {
if (Object.hasOwn(headersB, key)) {
result.set(key, (headersB)[key]);
result.set(key, (headersB as Record<string, any>)[key]);
}
}
return result;
return result as T;
}