From 88ee446910ca10296ed594fc3d711efe066522c4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 6 Jun 2026 13:26:16 -0700 Subject: [PATCH 1/3] Retry binary downloads on transient HTTP errors --- apps/code/scripts/download-binaries.mjs | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/code/scripts/download-binaries.mjs b/apps/code/scripts/download-binaries.mjs index a2058888c7..1aaabf937f 100644 --- a/apps/code/scripts/download-binaries.mjs +++ b/apps/code/scripts/download-binaries.mjs @@ -10,6 +10,7 @@ import { } from "node:fs"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; +import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { extract } from "tar"; @@ -75,14 +76,38 @@ const BINARIES = [ }, ]; +const MAX_DOWNLOAD_ATTEMPTS = 5; +const RETRIABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); + async function downloadFile(url, destPath) { console.log(` Downloading: ${url}`); - const response = await fetch(url, { redirect: "follow" }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + for (let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++) { + try { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + const error = new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + error.retriable = RETRIABLE_HTTP_STATUS.has(response.status); + throw error; + } + await pipeline(response.body, createWriteStream(destPath)); + console.log(` Saved to: ${destPath}`); + return; + } catch (error) { + // Network-level failures (ECONNRESET, ETIMEDOUT, socket hang up) and + // stream errors carry no `retriable` flag; treat those as transient too. + const isLastAttempt = attempt === MAX_DOWNLOAD_ATTEMPTS; + if (error.retriable === false || isLastAttempt) { + throw error; + } + const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000); + console.warn( + ` Attempt ${attempt}/${MAX_DOWNLOAD_ATTEMPTS} failed: ${error.message}. Retrying in ${delayMs / 1000}s...`, + ); + await sleep(delayMs); + } } - await pipeline(response.body, createWriteStream(destPath)); - console.log(` Saved to: ${destPath}`); } async function extractArchive(archivePath, destDir) { From fed5fe0c1fd48d6ee116a7331d0fb2e8761bfa2a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 7 Jun 2026 16:45:30 -0700 Subject: [PATCH 2/3] harden binary download retries and add tests --- apps/code/scripts/download-binaries.mjs | 53 ++++++--- apps/code/scripts/download-binaries.test.mjs | 115 +++++++++++++++++++ 2 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 apps/code/scripts/download-binaries.test.mjs diff --git a/apps/code/scripts/download-binaries.mjs b/apps/code/scripts/download-binaries.mjs index 1aaabf937f..34f0dc03a7 100644 --- a/apps/code/scripts/download-binaries.mjs +++ b/apps/code/scripts/download-binaries.mjs @@ -6,6 +6,7 @@ import { createWriteStream, existsSync, mkdirSync, + realpathSync, rmSync, } from "node:fs"; import { dirname, join } from "node:path"; @@ -76,34 +77,46 @@ const BINARIES = [ }, ]; -const MAX_DOWNLOAD_ATTEMPTS = 5; -const RETRIABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); +export const MAX_DOWNLOAD_ATTEMPTS = 5; +const RETRIABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); -async function downloadFile(url, destPath) { +// Thrown for HTTP statuses that will never succeed on retry (e.g. 404), so the +// loop fails fast on them while treating every other error as transient. +class NonRetriableError extends Error {} + +function backoffDelayMs(attempt) { + const base = Math.min(1000 * 2 ** (attempt - 1), 15000); + // Full jitter so parallel CI runners hitting a throttled CDN don't retry in lockstep. + return Math.floor(base * (0.5 + Math.random() * 0.5)); +} + +export async function downloadFile(url, destPath) { console.log(` Downloading: ${url}`); for (let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++) { try { const response = await fetch(url, { redirect: "follow" }); if (!response.ok) { - const error = new Error( - `HTTP ${response.status}: ${response.statusText}`, - ); - error.retriable = RETRIABLE_HTTP_STATUS.has(response.status); - throw error; + const message = `HTTP ${response.status}: ${response.statusText}`; + if (RETRIABLE_HTTP_STATUSES.has(response.status)) { + throw new Error(message); + } + throw new NonRetriableError(message); } await pipeline(response.body, createWriteStream(destPath)); console.log(` Saved to: ${destPath}`); return; } catch (error) { // Network-level failures (ECONNRESET, ETIMEDOUT, socket hang up) and - // stream errors carry no `retriable` flag; treat those as transient too. - const isLastAttempt = attempt === MAX_DOWNLOAD_ATTEMPTS; - if (error.retriable === false || isLastAttempt) { + // stream errors are transient; only a NonRetriableError fails fast. + if ( + error instanceof NonRetriableError || + attempt === MAX_DOWNLOAD_ATTEMPTS + ) { throw error; } - const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000); + const delayMs = backoffDelayMs(attempt); console.warn( - ` Attempt ${attempt}/${MAX_DOWNLOAD_ATTEMPTS} failed: ${error.message}. Retrying in ${delayMs / 1000}s...`, + ` Attempt ${attempt}/${MAX_DOWNLOAD_ATTEMPTS} failed: ${error.message}. Retrying in ${(delayMs / 1000).toFixed(1)}s...`, ); await sleep(delayMs); } @@ -181,7 +194,13 @@ async function main() { console.log("\nDone."); } -main().catch((err) => { - console.error("\nFailed:", err.message); - process.exit(1); -}); +// Only run when executed directly (e.g. via postinstall), not when imported by tests. +const isEntrypoint = + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); +if (isEntrypoint) { + main().catch((err) => { + console.error("\nFailed:", err.message); + process.exit(1); + }); +} diff --git a/apps/code/scripts/download-binaries.test.mjs b/apps/code/scripts/download-binaries.test.mjs new file mode 100644 index 0000000000..6d6fd04dd4 --- /dev/null +++ b/apps/code/scripts/download-binaries.test.mjs @@ -0,0 +1,115 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadFile, MAX_DOWNLOAD_ATTEMPTS } from "./download-binaries.mjs"; + +// Resolve sleep instantly and stub out the file write so downloadFile's retry +// control flow can be tested without real delays or filesystem access. +vi.mock("node:timers/promises", () => { + const setTimeout = vi.fn(() => Promise.resolve()); + return { setTimeout, default: { setTimeout } }; +}); +vi.mock("node:stream/promises", () => { + const pipeline = vi.fn(() => Promise.resolve()); + return { pipeline, default: { pipeline } }; +}); +vi.mock("node:fs", () => { + const fns = { + chmodSync: vi.fn(), + createWriteStream: vi.fn(() => ({})), + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), + realpathSync: vi.fn(() => "/not/the/entrypoint"), + rmSync: vi.fn(), + }; + return { ...fns, default: fns }; +}); + +const okResponse = () => ({ + ok: true, + status: 200, + statusText: "OK", + body: {}, +}); +const errorResponse = (status, statusText) => ({ + ok: false, + status, + statusText, + body: null, +}); + +describe("downloadFile", () => { + let fetchMock; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("downloads on the first attempt without retrying", async () => { + fetchMock.mockResolvedValue(okResponse()); + + await downloadFile("https://example.test/bin.tar.gz", "/tmp/bin.tar.gz"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries retriable HTTP statuses then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(errorResponse(503, "Service Unavailable")) + .mockResolvedValueOnce(errorResponse(504, "Gateway Time-out")) + .mockResolvedValueOnce(okResponse()); + + await downloadFile("u", "/tmp/bin"); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("fails fast on non-retriable HTTP statuses", async () => { + fetchMock.mockResolvedValue(errorResponse(404, "Not Found")); + + await expect(downloadFile("u", "/tmp/bin")).rejects.toThrow("HTTP 404"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("retries network-level errors that carry no HTTP status", async () => { + fetchMock + .mockRejectedValueOnce(new Error("ECONNRESET")) + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce(okResponse()); + + await downloadFile("u", "/tmp/bin"); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it("gives up after MAX_DOWNLOAD_ATTEMPTS and rethrows the last error", async () => { + fetchMock.mockResolvedValue(errorResponse(503, "Service Unavailable")); + + await expect(downloadFile("u", "/tmp/bin")).rejects.toThrow("HTTP 503"); + expect(fetchMock).toHaveBeenCalledTimes(MAX_DOWNLOAD_ATTEMPTS); + expect(sleep).toHaveBeenCalledTimes(MAX_DOWNLOAD_ATTEMPTS - 1); + }); + + it("backs off exponentially with jitter inside the expected bounds", async () => { + fetchMock.mockResolvedValue(errorResponse(503, "Service Unavailable")); + + await expect(downloadFile("u", "/tmp/bin")).rejects.toThrow(); + + const delays = sleep.mock.calls.map(([ms]) => ms); + expect(delays).toHaveLength(MAX_DOWNLOAD_ATTEMPTS - 1); + delays.forEach((delay, i) => { + const base = Math.min(1000 * 2 ** i, 15000); + expect(delay).toBeGreaterThanOrEqual(base * 0.5); + expect(delay).toBeLessThan(base); + }); + }); +}); From 89dfb6d2110b06040e2bdf561285aa978d678baa Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 8 Jun 2026 18:08:33 -0700 Subject: [PATCH 3/3] remove verbose binary download comments --- apps/code/scripts/download-binaries.mjs | 6 ------ apps/code/scripts/download-binaries.test.mjs | 2 -- 2 files changed, 8 deletions(-) diff --git a/apps/code/scripts/download-binaries.mjs b/apps/code/scripts/download-binaries.mjs index 34f0dc03a7..26a3e10f6a 100644 --- a/apps/code/scripts/download-binaries.mjs +++ b/apps/code/scripts/download-binaries.mjs @@ -80,13 +80,10 @@ const BINARIES = [ export const MAX_DOWNLOAD_ATTEMPTS = 5; const RETRIABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); -// Thrown for HTTP statuses that will never succeed on retry (e.g. 404), so the -// loop fails fast on them while treating every other error as transient. class NonRetriableError extends Error {} function backoffDelayMs(attempt) { const base = Math.min(1000 * 2 ** (attempt - 1), 15000); - // Full jitter so parallel CI runners hitting a throttled CDN don't retry in lockstep. return Math.floor(base * (0.5 + Math.random() * 0.5)); } @@ -106,8 +103,6 @@ export async function downloadFile(url, destPath) { console.log(` Saved to: ${destPath}`); return; } catch (error) { - // Network-level failures (ECONNRESET, ETIMEDOUT, socket hang up) and - // stream errors are transient; only a NonRetriableError fails fast. if ( error instanceof NonRetriableError || attempt === MAX_DOWNLOAD_ATTEMPTS @@ -194,7 +189,6 @@ async function main() { console.log("\nDone."); } -// Only run when executed directly (e.g. via postinstall), not when imported by tests. const isEntrypoint = process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); diff --git a/apps/code/scripts/download-binaries.test.mjs b/apps/code/scripts/download-binaries.test.mjs index 6d6fd04dd4..85e01b8085 100644 --- a/apps/code/scripts/download-binaries.test.mjs +++ b/apps/code/scripts/download-binaries.test.mjs @@ -2,8 +2,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { downloadFile, MAX_DOWNLOAD_ATTEMPTS } from "./download-binaries.mjs"; -// Resolve sleep instantly and stub out the file write so downloadFile's retry -// control flow can be tested without real delays or filesystem access. vi.mock("node:timers/promises", () => { const setTimeout = vi.fn(() => Promise.resolve()); return { setTimeout, default: { setTimeout } };