Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions modes/go.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {env} from "node:process";
import {join, dirname} from "node:path";
import {readFileSync, globSync} from "node:fs";
import {type Deps, type ModeContext, type PackageInfo, fieldSep, stripv, getSubDir, normalizeUrl} from "./shared.ts";
import {
type Deps, type ModeContext, type PackageInfo, fieldSep, stripv, getSubDir, normalizeUrl, fetchWithRetry,
} from "./shared.ts";
import {esc} from "../utils/utils.ts";

let execFilePromise: ReturnType<typeof loadExecFile> | undefined;
Expand Down Expand Up @@ -256,7 +258,7 @@ export async function fetchGoProxyInfo(name: string, type: string, currentVersio
// Fetch @latest and probe for next major version in parallel
const skip = shouldSkipMajorProbe(name, type, currentVersion);
const [res, earlyProbe] = await Promise.all([
ctx.doFetch(`${ctx.goProxyUrl}/${encoded}/@latest`, {signal: AbortSignal.timeout(ctx.fetchTimeout), headers: {"accept-encoding": "gzip, deflate, br"}}),
fetchWithRetry(ctx, `${ctx.goProxyUrl}/${encoded}/@latest`, {headers: {"accept-encoding": "gzip, deflate, br"}}),
skip ? null : probeGoMajor(currentMajor + 1),
]);
if (!res.ok) return noUpdateInfo(name, currentVersion, type);
Expand Down
4 changes: 2 additions & 2 deletions modes/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {getCache, setCache} from "../utils/fetchCache.ts";
import {
type Config, type CheckResult, type Dep, type Deps, type ModeContext, type PackageInfo, type PackageRepository,
normalizeUrl, getFetchOpts, fieldSep, fetchForge, selectTag, fetchWithEtag, fetchImmutable,
coerceToVersion, hashRe, fetchActionTags, throwFetchError,
coerceToVersion, hashRe, fetchActionTags, throwFetchError, fetchWithRetry,
} from "./shared.ts";

export type Npmrc = {
Expand Down Expand Up @@ -214,7 +214,7 @@ export async function fetchNpmVersionInfo(name: string, version: string, config:
if (!fullPromise) {
fullPromise = (async () => {
try {
const res = await ctx.doFetch(fullUrl, {...fetchOpts, signal: AbortSignal.timeout(ctx.fetchTimeout)});
const res = await fetchWithRetry(ctx, fullUrl, fetchOpts);
return res?.ok ? await res.json() : null;
} catch {
npmFullDataCache.delete(fullUrl);
Expand Down
52 changes: 40 additions & 12 deletions modes/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const packageVersion = pkg.version;
export const fieldSep = "\0";
export const fetchTimeout = 5000;
export const goProbeTimeout = 2500;
export const fetchRetries = 2;

export const stripv = (str: string): string => str[0] === "v" ? str.substring(1) : str;
export const normalizeUrl = (url: string) => url.endsWith("/") ? url.slice(0, -1) : url;
Expand All @@ -110,11 +111,40 @@ export function getFetchOpts(authType?: string, authToken?: string): RequestInit
};
}

// Retryable failures; deterministic ones (bad URL, NXDOMAIN, TLS) are not.
const transientErrorCodes = new Set([
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN", "EPIPE",
"UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT",
]);

function isTransientFetchError(err: any): boolean {
if (err?.name === "TimeoutError" || err?.name === "AbortError") return true;
// fetch wraps socket/DNS errors as a TypeError with the real error in `cause`.
const code = err?.code ?? err?.cause?.code;
return typeof code === "string" && transientErrorCodes.has(code);
}

export async function doFetch(url: string, opts?: RequestInit): Promise<Response> {
try {
return await fetch(url, opts);
} catch (err: any) {
throw new Error(`Failed to fetch ${url}${err?.message ? `: ${err.message}` : ""}`);
const error: any = new Error(`Failed to fetch ${url}${err?.message ? `: ${err.message}` : ""}`, {cause: err});
error.transient = isTransientFetchError(err);
throw error;
}
}

// Retry only transient failures; a fresh AbortSignal is made per attempt since
// an aborted one can't be reused. Non-ok responses resolve normally, not retried.
export async function fetchWithRetry(
ctx: ModeContext, url: string, opts: RequestInit = {},
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
return await ctx.doFetch(url, {...opts, signal: AbortSignal.timeout(ctx.fetchTimeout)});
} catch (err: any) {
if (attempt >= fetchRetries || !err?.transient) throw err;
}
}
}

Expand Down Expand Up @@ -143,16 +173,16 @@ function reduceBody(body: string, reduce: BodyReducer | undefined): string {
}
}

// Fetch with ETag revalidation against the persistent disk cache. The fetch
// timeout signal is created inside, after the cache read, so slow disks do
// not eat the network budget. Returns {body} on success, or {res} on error.
// Fetch with ETag revalidation against the persistent disk cache. The timeout
// signal is created after the cache read, so slow disks do not eat the network
// budget. Returns {body} on success, or {res} on error.
export async function fetchWithEtag(
url: string, ctx: ModeContext, opts: RequestInit = {}, reduce?: BodyReducer,
): Promise<{body: string, res?: Response} | {res: Response | undefined}> {
const cached = ctx.noCache ? null : await getCache(url);
const baseHeaders = opts.headers as Record<string, string> | undefined;
const headers = cached ? {...baseHeaders, "if-none-match": cached.etag} : baseHeaders;
const res = await ctx.doFetch(url, {...opts, headers, signal: AbortSignal.timeout(ctx.fetchTimeout)});
const res = await fetchWithRetry(ctx, url, {...opts, headers});
if (!res) return {res: undefined};
if (res.status === 304 && cached) return {body: cached.body, res};
if (!res.ok) return {res};
Expand All @@ -171,7 +201,7 @@ export async function fetchImmutable(
const cached = await getCache(url);
if (cached) return {body: cached.body};
}
const res = await ctx.doFetch(url, {...opts, signal: AbortSignal.timeout(ctx.fetchTimeout)});
const res = await fetchWithRetry(ctx, url, opts);
if (!res) return {res: undefined};
if (!res.ok) return {res};
const body = reduceBody(await readBody(res), reduce);
Expand Down Expand Up @@ -509,27 +539,25 @@ export async function fetchForge(url: string, ctx: ModeContext, extraHeaders?: R
// `gh auth token` probe does not consume the fetch's timeout budget.
const tokens = await getForgeTokens(hostname, ctx.forgeApiUrl);

const signal = AbortSignal.timeout(ctx.fetchTimeout);
const optsFor = (token?: string): RequestInit => {
const opts = getFetchOpts("Bearer", token);
if (extraHeaders) opts.headers = {...opts.headers as Record<string, string>, ...extraHeaders};
opts.signal = signal;
return opts;
};

if (!tokens.length) return ctx.doFetch(url, optsFor());
if (!tokens.length) return fetchWithRetry(ctx, url, optsFor());

const cached = hostname ? workingTokenCache.get(hostname) : undefined;
if (cached) return ctx.doFetch(url, optsFor(cached));
if (cached) return fetchWithRetry(ctx, url, optsFor(cached));

for (const token of tokens) {
const response = await ctx.doFetch(url, optsFor(token));
const response = await fetchWithRetry(ctx, url, optsFor(token));
if (response.status !== 401 && response.status !== 403) {
if (hostname) workingTokenCache.set(hostname, token);
return response;
}
}
return ctx.doFetch(url, optsFor());
return fetchWithRetry(ctx, url, optsFor());
}

// Picks the highest valid semver tag. GitHub does not guarantee a particular
Expand Down
4 changes: 3 additions & 1 deletion utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ export async function tryOrNull<T>(promise: Promise<T>): Promise<T | null> {
}
}

export const esc = (str: string) => RegExp.escape(str);
// RegExp.escape needs Node 24; fall back to a manual escape on Node 22.
export const esc = (str: string) =>
RegExp.escape ? RegExp.escape(str) : str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

export async function walkUp<T>(startDir: string, probe: (dir: string) => Promise<T | null>): Promise<T | null> {
let dir = startDir;
Expand Down
Loading