diff --git a/dist/main/index.js b/dist/main/index.js index a586e38..ead724f 100644 --- a/dist/main/index.js +++ b/dist/main/index.js @@ -69877,6 +69877,43 @@ function requireEnv(name) { return value; } +// src/retry.ts +var TransientError = class extends Error { + name = "TransientError"; +}; +function isTransient(err) { + if (err instanceof TransientError) return true; + if (!(err instanceof Error)) return false; + if (err.name === "AbortError" || err.name === "TimeoutError") return true; + const msg = err.message; + const http_match = /HTTP\D+(\d{3})/i.exec(msg); + if (http_match) { + const code = Number(http_match[1]); + return code >= 500 && code < 600; + } + return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|socket hang up/i.test(msg); +} +async function withRetry(fn, opts = {}) { + const retries = opts.retries ?? 1; + const min_delay = opts.minDelayMs ?? 500; + const jitter = opts.jitterMs ?? 500; + const classify = opts.isTransient ?? isTransient; + const sleep2 = opts.sleep ?? defaultSleep; + let attempt = 0; + for (; ; ) { + try { + return await fn(); + } catch (err) { + if (attempt >= retries || !classify(err)) throw err; + attempt++; + await sleep2(min_delay + Math.floor(Math.random() * jitter)); + } + } +} +function defaultSleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + // src/common.ts var VERSIONS_JSON = "https://ziglang.org/download/index.json"; var MACH_VERSIONS_JSON = "https://pkg.machengine.org/zig/index.json"; @@ -69970,11 +70007,13 @@ function compareReleaseParts(a, b) { return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; } async function fetchJsonWithTimeout(url2, timeout_ms) { - const resp = await fetch(url2, { signal: AbortSignal.timeout(timeout_ms) }); - if (!resp.ok) { - throw new Error(`Fetch ${url2} failed: HTTP ${resp.status}`); - } - return await resp.json(); + return await withRetry(async () => { + const resp = await fetch(url2, { signal: AbortSignal.timeout(timeout_ms) }); + if (!resp.ok) { + throw new Error(`Fetch ${url2} failed: HTTP ${resp.status}`); + } + return await resp.json(); + }); } async function getTarballName() { const version3 = await getVersion2(); @@ -70196,43 +70235,6 @@ async function runAttempt(item, thunk, log2) { } } -// src/retry.ts -var TransientError = class extends Error { - name = "TransientError"; -}; -function isTransient(err) { - if (err instanceof TransientError) return true; - if (!(err instanceof Error)) return false; - if (err.name === "AbortError") return true; - const msg = err.message; - const http_match = /HTTP\D+(\d{3})/i.exec(msg); - if (http_match) { - const code = Number(http_match[1]); - return code >= 500 && code < 600; - } - return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|socket hang up/i.test(msg); -} -async function withRetry(fn, opts = {}) { - const retries = opts.retries ?? 1; - const min_delay = opts.minDelayMs ?? 500; - const jitter = opts.jitterMs ?? 500; - const classify = opts.isTransient ?? isTransient; - const sleep2 = opts.sleep ?? defaultSleep; - let attempt = 0; - for (; ; ) { - try { - return await fn(); - } catch (err) { - if (attempt >= retries || !classify(err)) throw err; - attempt++; - await sleep2(min_delay + Math.floor(Math.random() * jitter)); - } - } -} -function defaultSleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - // src/source-tag.ts function withSource(url2, tag) { if (!tag) return url2; diff --git a/dist/post/index.js b/dist/post/index.js index 5deeff1..37a4d4f 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -68725,6 +68725,43 @@ function errMessage(err) { } } +// src/retry.ts +var TransientError = class extends Error { + name = "TransientError"; +}; +function isTransient(err) { + if (err instanceof TransientError) return true; + if (!(err instanceof Error)) return false; + if (err.name === "AbortError" || err.name === "TimeoutError") return true; + const msg = err.message; + const http_match = /HTTP\D+(\d{3})/i.exec(msg); + if (http_match) { + const code = Number(http_match[1]); + return code >= 500 && code < 600; + } + return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|ECONNREFUSED|socket hang up/i.test(msg); +} +async function withRetry(fn, opts = {}) { + const retries = opts.retries ?? 1; + const min_delay = opts.minDelayMs ?? 500; + const jitter = opts.jitterMs ?? 500; + const classify = opts.isTransient ?? isTransient; + const sleep2 = opts.sleep ?? defaultSleep; + let attempt = 0; + for (; ; ) { + try { + return await fn(); + } catch (err) { + if (attempt >= retries || !classify(err)) throw err; + attempt++; + await sleep2(min_delay + Math.floor(Math.random() * jitter)); + } + } +} +function defaultSleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + // src/common.ts var VERSIONS_JSON = "https://ziglang.org/download/index.json"; var MACH_VERSIONS_JSON = "https://pkg.machengine.org/zig/index.json"; @@ -68818,11 +68855,13 @@ function compareReleaseParts(a, b) { return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; } async function fetchJsonWithTimeout(url2, timeout_ms) { - const resp = await fetch(url2, { signal: AbortSignal.timeout(timeout_ms) }); - if (!resp.ok) { - throw new Error(`Fetch ${url2} failed: HTTP ${resp.status}`); - } - return await resp.json(); + return await withRetry(async () => { + const resp = await fetch(url2, { signal: AbortSignal.timeout(timeout_ms) }); + if (!resp.ok) { + throw new Error(`Fetch ${url2} failed: HTTP ${resp.status}`); + } + return await resp.json(); + }); } async function getTarballName() { const version3 = await getVersion2(); diff --git a/src/common.ts b/src/common.ts index 1dbdfe7..9d92a96 100644 --- a/src/common.ts +++ b/src/common.ts @@ -10,6 +10,7 @@ import { lookupMachVersion, } from './schema.ts'; import { errMessage, isErrnoException } from './util.ts'; +import { withRetry } from './retry.ts'; const VERSIONS_JSON = 'https://ziglang.org/download/index.json'; const MACH_VERSIONS_JSON = 'https://pkg.machengine.org/zig/index.json'; @@ -123,12 +124,17 @@ function compareReleaseParts(a: [number, number, number], b: [number, number, nu return (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]); } +// Version resolution happens before any mirror is contacted, so a single +// slow response here fails the whole job with nothing else attempted. Retry +// transient failures (timeout, 5xx, connection reset) once. async function fetchJsonWithTimeout(url: string, timeout_ms: number): Promise { - const resp = await fetch(url, { signal: AbortSignal.timeout(timeout_ms) }); - if (!resp.ok) { - throw new Error(`Fetch ${url} failed: HTTP ${resp.status}`); - } - return await resp.json(); + return await withRetry(async () => { + const resp = await fetch(url, { signal: AbortSignal.timeout(timeout_ms) }); + if (!resp.ok) { + throw new Error(`Fetch ${url} failed: HTTP ${resp.status}`); + } + return await resp.json(); + }); } export async function getTarballName(): Promise { diff --git a/src/retry.ts b/src/retry.ts index c6a97d3..9da2e9f 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -11,7 +11,9 @@ export function isTransient(err: unknown): boolean { if (!(err instanceof Error)) return false; // AbortError sets err.name; the message ("The operation was aborted") does // not mention "abort" in a way we can grep reliably across runtimes. - if (err.name === 'AbortError') return true; + // AbortSignal.timeout() rejects with a DOMException named TimeoutError + // rather than AbortError — a slow mirror is transient, so retry it too. + if (err.name === 'AbortError' || err.name === 'TimeoutError') return true; const msg = err.message; // @actions/tool-cache and friends emit "Unexpected HTTP response: 5xx"; // accept any non-digit run between "HTTP" and the status code. diff --git a/test/retry.test.ts b/test/retry.test.ts index 98488a2..6ea25b6 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -34,6 +34,18 @@ test('isTransient: common network errnos are transient', () => { assert.equal(isTransient(aborted), true); }); +test('isTransient: AbortSignal.timeout rejection is transient', async () => { + // The real shape a timed-out fetch produces: a DOMException named + // TimeoutError whose message mentions neither "abort" nor an errno. + const err = await new Promise(resolve => { + AbortSignal.timeout(1).addEventListener('abort', function (this: AbortSignal) { + resolve(this.reason); + }); + }); + assert.equal((err as Error).name, 'TimeoutError'); + assert.equal(isTransient(err), true); +}); + test('isTransient: arbitrary application error is NOT transient', () => { assert.equal(isTransient(new Error('signature verification failed')), false); assert.equal(isTransient(new Error('filename mismatch')), false);