From 921602fb84e44010827c98df16bfea89f281ba0c Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 2 Jul 2026 11:18:28 +0200 Subject: [PATCH 1/4] Retry transient network fetch failures A single slow or dropped registry request currently aborts the whole run. Add a fetchRetrying wrapper that retries timeouts and transient socket/DNS failures up to twice with a fresh timeout per attempt. Deterministic errors (bad URL, NXDOMAIN, TLS) and HTTP error statuses still fail on the first attempt. Route the cached-registry, forge, npm and go @latest fetches through it; speculative go/make major-version probes stay single-shot. Co-Authored-By: Claude (Opus 4.8) --- README.md | 2 +- index.ts | 2 +- modes/go.ts | 6 ++++-- modes/npm.ts | 4 ++-- modes/shared.ts | 54 +++++++++++++++++++++++++++++++++++++++---------- 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ed4bd6e..10e76cf 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npx updates -u && npm i |`-m, --minor []`|Consider only up to semver-minor| |`-d, --allow-downgrade []`|Allow version downgrades when using latest version| |`-s, --sockets `|Maximum number of parallel HTTP sockets opened. Default: 96| -|`-T, --timeout `|Network request timeout in ms (go probes use half). Default: 5000| +|`-T, --timeout `|Network timeout in ms per attempt, 2 retries on transient errors (go probes use half, no retry). Default: 5000| |`-r, --registry `|Override npm registry URL| |`-I, --indirect`|Include indirect Go dependencies| |`-E, --error-on-outdated`|Exit with code 2 when updates are available and 0 when not| diff --git a/index.ts b/index.ts index 96f9c71..ea2bffa 100755 --- a/index.ts +++ b/index.ts @@ -137,7 +137,7 @@ async function main(): Promise { -m, --minor [] Consider only up to semver-minor -d, --allow-downgrade [] Allow version downgrades when using latest version -s, --sockets Maximum number of parallel HTTP sockets opened. Default: ${maxSockets} - -T, --timeout Network request timeout in ms (go probes use half). Default: ${fetchTimeout} + -T, --timeout Network timeout in ms per attempt, 2 retries on transient errors (go probes use half, no retry). Default: ${fetchTimeout} -r, --registry Override npm registry URL -I, --indirect Include indirect Go dependencies -E, --error-on-outdated Exit with code 2 when updates are available and 0 when not diff --git a/modes/go.ts b/modes/go.ts index 9f1e747..224ba17 100644 --- a/modes/go.ts +++ b/modes/go.ts @@ -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, fetchRetrying, +} from "./shared.ts"; import {esc} from "../utils/utils.ts"; let execFilePromise: ReturnType | undefined; @@ -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"}}), + fetchRetrying(ctx, `${ctx.goProxyUrl}/${encoded}/@latest`, {headers: {"accept-encoding": "gzip, deflate, br"}}), skip ? null : probeGoMajor(currentMajor + 1), ]); if (!res.ok) return noUpdateInfo(name, currentVersion, type); diff --git a/modes/npm.ts b/modes/npm.ts index 39655c1..4f11479 100644 --- a/modes/npm.ts +++ b/modes/npm.ts @@ -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, fetchRetrying, } from "./shared.ts"; export type Npmrc = { @@ -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 fetchRetrying(ctx, fullUrl, fetchOpts); return res?.ok ? await res.json() : null; } catch { npmFullDataCache.delete(fullUrl); diff --git a/modes/shared.ts b/modes/shared.ts index bcdf984..b70dd4f 100644 --- a/modes/shared.ts +++ b/modes/shared.ts @@ -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; @@ -110,11 +111,43 @@ export function getFetchOpts(authType?: string, authToken?: string): RequestInit }; } +// Errors worth retrying: request timeouts and transient socket/DNS failures. +// Deterministic failures (bad URL, NXDOMAIN, TLS errors) fail on first attempt. +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 { 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 a fetch on transient network failure (timeout / connection reset). A +// fresh AbortSignal is created per attempt since an aborted one can't be reused. +// Deterministic errors and HTTP error statuses (4xx/5xx) are not retried — +// they throw or resolve on the first attempt for callers to handle. +export async function fetchRetrying( + ctx: ModeContext, url: string, opts: RequestInit = {}, +): Promise { + 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; + } } } @@ -144,15 +177,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. +// timeout signal is created per attempt inside fetchRetrying, 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 | 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 fetchRetrying(ctx, url, {...opts, headers}); if (!res) return {res: undefined}; if (res.status === 304 && cached) return {body: cached.body, res}; if (!res.ok) return {res}; @@ -171,7 +205,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 fetchRetrying(ctx, url, opts); if (!res) return {res: undefined}; if (!res.ok) return {res}; const body = reduceBody(await readBody(res), reduce); @@ -509,27 +543,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, ...extraHeaders}; - opts.signal = signal; return opts; }; - if (!tokens.length) return ctx.doFetch(url, optsFor()); + if (!tokens.length) return fetchRetrying(ctx, url, optsFor()); const cached = hostname ? workingTokenCache.get(hostname) : undefined; - if (cached) return ctx.doFetch(url, optsFor(cached)); + if (cached) return fetchRetrying(ctx, url, optsFor(cached)); for (const token of tokens) { - const response = await ctx.doFetch(url, optsFor(token)); + const response = await fetchRetrying(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 fetchRetrying(ctx, url, optsFor()); } // Picks the highest valid semver tag. GitHub does not guarantee a particular From d8dc806b4572e76b955042eab3fe1d46d5395a56 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 2 Jul 2026 11:20:58 +0200 Subject: [PATCH 2/4] Revert -T help text to original wording Default is back to 5000 and retries are transient-only, so the extra detail is not worth the longer line. Co-Authored-By: Claude (Opus 4.8) --- README.md | 2 +- index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10e76cf..ed4bd6e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npx updates -u && npm i |`-m, --minor []`|Consider only up to semver-minor| |`-d, --allow-downgrade []`|Allow version downgrades when using latest version| |`-s, --sockets `|Maximum number of parallel HTTP sockets opened. Default: 96| -|`-T, --timeout `|Network timeout in ms per attempt, 2 retries on transient errors (go probes use half, no retry). Default: 5000| +|`-T, --timeout `|Network request timeout in ms (go probes use half). Default: 5000| |`-r, --registry `|Override npm registry URL| |`-I, --indirect`|Include indirect Go dependencies| |`-E, --error-on-outdated`|Exit with code 2 when updates are available and 0 when not| diff --git a/index.ts b/index.ts index ea2bffa..96f9c71 100755 --- a/index.ts +++ b/index.ts @@ -137,7 +137,7 @@ async function main(): Promise { -m, --minor [] Consider only up to semver-minor -d, --allow-downgrade [] Allow version downgrades when using latest version -s, --sockets Maximum number of parallel HTTP sockets opened. Default: ${maxSockets} - -T, --timeout Network timeout in ms per attempt, 2 retries on transient errors (go probes use half, no retry). Default: ${fetchTimeout} + -T, --timeout Network request timeout in ms (go probes use half). Default: ${fetchTimeout} -r, --registry Override npm registry URL -I, --indirect Include indirect Go dependencies -E, --error-on-outdated Exit with code 2 when updates are available and 0 when not From dac8b0bfca9ea4e5751dd88d7c5c4d7a24292d86 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 2 Jul 2026 11:26:08 +0200 Subject: [PATCH 3/4] Rename fetchRetrying to fetchWithRetry, trim comments Matches the existing fetchWithEtag naming. Co-Authored-By: Claude (Opus 4.8) --- modes/go.ts | 4 ++-- modes/npm.ts | 4 ++-- modes/shared.ts | 30 +++++++++++++----------------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/modes/go.ts b/modes/go.ts index 224ba17..f43f7ce 100644 --- a/modes/go.ts +++ b/modes/go.ts @@ -2,7 +2,7 @@ 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, fetchRetrying, + type Deps, type ModeContext, type PackageInfo, fieldSep, stripv, getSubDir, normalizeUrl, fetchWithRetry, } from "./shared.ts"; import {esc} from "../utils/utils.ts"; @@ -258,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([ - fetchRetrying(ctx, `${ctx.goProxyUrl}/${encoded}/@latest`, {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); diff --git a/modes/npm.ts b/modes/npm.ts index 4f11479..19e2a07 100644 --- a/modes/npm.ts +++ b/modes/npm.ts @@ -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, fetchRetrying, + coerceToVersion, hashRe, fetchActionTags, throwFetchError, fetchWithRetry, } from "./shared.ts"; export type Npmrc = { @@ -214,7 +214,7 @@ export async function fetchNpmVersionInfo(name: string, version: string, config: if (!fullPromise) { fullPromise = (async () => { try { - const res = await fetchRetrying(ctx, fullUrl, fetchOpts); + const res = await fetchWithRetry(ctx, fullUrl, fetchOpts); return res?.ok ? await res.json() : null; } catch { npmFullDataCache.delete(fullUrl); diff --git a/modes/shared.ts b/modes/shared.ts index b70dd4f..3fcc14d 100644 --- a/modes/shared.ts +++ b/modes/shared.ts @@ -111,8 +111,7 @@ export function getFetchOpts(authType?: string, authToken?: string): RequestInit }; } -// Errors worth retrying: request timeouts and transient socket/DNS failures. -// Deterministic failures (bad URL, NXDOMAIN, TLS errors) fail on first attempt. +// 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", @@ -135,11 +134,9 @@ export async function doFetch(url: string, opts?: RequestInit): Promise { for (let attempt = 0; ; attempt++) { @@ -176,17 +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 per attempt inside fetchRetrying, 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 | undefined; const headers = cached ? {...baseHeaders, "if-none-match": cached.etag} : baseHeaders; - const res = await fetchRetrying(ctx, url, {...opts, headers}); + 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}; @@ -205,7 +201,7 @@ export async function fetchImmutable( const cached = await getCache(url); if (cached) return {body: cached.body}; } - const res = await fetchRetrying(ctx, url, opts); + const res = await fetchWithRetry(ctx, url, opts); if (!res) return {res: undefined}; if (!res.ok) return {res}; const body = reduceBody(await readBody(res), reduce); @@ -549,19 +545,19 @@ export async function fetchForge(url: string, ctx: ModeContext, extraHeaders?: R return opts; }; - if (!tokens.length) return fetchRetrying(ctx, url, optsFor()); + if (!tokens.length) return fetchWithRetry(ctx, url, optsFor()); const cached = hostname ? workingTokenCache.get(hostname) : undefined; - if (cached) return fetchRetrying(ctx, url, optsFor(cached)); + if (cached) return fetchWithRetry(ctx, url, optsFor(cached)); for (const token of tokens) { - const response = await fetchRetrying(ctx, 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 fetchRetrying(ctx, url, optsFor()); + return fetchWithRetry(ctx, url, optsFor()); } // Picks the highest valid semver tag. GitHub does not guarantee a particular From c10673e17dd6dd21be25b67be4eeddc9ac296339 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 2 Jul 2026 11:29:56 +0200 Subject: [PATCH 4/4] Fall back to manual regex escape on Node 22 RegExp.escape needs Node 24, but engines allows >=22, so CI failed on Node 22. Use a manual escape when RegExp.escape is unavailable. Co-Authored-By: Claude (Opus 4.8) --- utils/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utils/utils.ts b/utils/utils.ts index 101ea82..18d81d5 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -167,7 +167,9 @@ export async function tryOrNull(promise: Promise): Promise { } } -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(startDir: string, probe: (dir: string) => Promise): Promise { let dir = startDir;