diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index c9c4d0d05a..c9885e1dc7 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -10,10 +10,14 @@ on: - "v1.*" paths: - "src/tools/**" + - "src/core/download.ts" + - "src/core/retry.ts" - ".github/workflows/test-install.yml" pull_request: paths: - "src/tools/**" + - "src/core/download.ts" + - "src/core/retry.ts" - ".github/workflows/test-install.yml" schedule: # Weekly Monday 9am UTC — detect upstream CDN/API breakage @@ -40,11 +44,31 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - quarto install tinytex + for attempt in 1 2 3; do + if quarto install tinytex; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + echo "::warning::Attempt $attempt failed, retrying in 15s..." + sleep 15 + fi + done + echo "::error::TinyTeX install failed after 3 attempts" + exit 1 - name: Install Chrome Headless Shell run: | - quarto install chrome-headless-shell --no-prompt + for attempt in 1 2 3; do + if quarto install chrome-headless-shell --no-prompt; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + echo "::warning::Attempt $attempt failed, retrying in 15s..." + sleep 15 + fi + done + echo "::error::Chrome Headless Shell install failed after 3 attempts" + exit 1 - name: Verify tools with quarto check run: | diff --git a/src/core/download.ts b/src/core/download.ts index 88608169f3..2f3bdee4de 100644 --- a/src/core/download.ts +++ b/src/core/download.ts @@ -6,6 +6,7 @@ import { writeAll } from "io/write-all"; import { progressBar } from "./console.ts"; +import { withRetry } from "./retry.ts"; export interface DownloadError extends Error { statusCode: number; @@ -17,42 +18,62 @@ export async function downloadWithProgress( msg: string, toFile: string, ) { - // Fetch the data - const response = await (typeof url === "string" - ? fetch( - url, - { - redirect: "follow", - }, - ) - : url); + await withRetry(async () => { + // Fetch the data + const response = await (typeof url === "string" + ? fetch( + url, + { + redirect: "follow", + }, + ) + : url); - // Write the data to a file - if (response.status === 200 && response.body) { - const pkgFile = await Deno.open(toFile, { create: true, write: true }); + // Write the data to a file + if (response.status === 200 && response.body) { + const pkgFile = await Deno.open( + toFile, + { create: true, write: true, truncate: true }, + ); - const contentLength = - (response.headers.get("content-length") || 0) as number; - const contentLengthMb = contentLength / 1024 / 1024; + const contentLength = + (response.headers.get("content-length") || 0) as number; + const contentLengthMb = contentLength / 1024 / 1024; - const prog = progressBar(contentLengthMb, msg); + const prog = progressBar(contentLengthMb, msg); - let totalLength = 0; - for await (const chunk of response.body) { - await writeAll(pkgFile, chunk); - totalLength = totalLength + chunk.length; - if (contentLength > 0) { - prog.update( - totalLength / 1024 / 1024, - `${(totalLength / 1024 / 1024).toFixed(1)}MB`, - ); + try { + let totalLength = 0; + for await (const chunk of response.body) { + await writeAll(pkgFile, chunk); + totalLength = totalLength + chunk.length; + if (contentLength > 0) { + prog.update( + totalLength / 1024 / 1024, + `${(totalLength / 1024 / 1024).toFixed(1)}MB`, + ); + } + } + prog.complete(); + } finally { + pkgFile.close(); } + } else { + throw new Error( + `download failed (HTTP status ${response.status} - ${response.statusText})`, + ); } - prog.complete(); - pkgFile.close(); - } else { - throw new Error( - `download failed (HTTP status ${response.status} - ${response.statusText})`, - ); - } + }, { + attempts: 3, + minWait: 2000, + maxWait: 10000, + retry: (err: Error) => { + // Don't retry HTTP status errors (4xx, 5xx) — they're deterministic + if (err.message.startsWith("download failed (HTTP status")) { + return false; + } + // Retry network errors (connection reset, timeout, body read errors) + return true; + }, + }); } diff --git a/src/core/retry.ts b/src/core/retry.ts index 017560a8b6..0b89d13324 100644 --- a/src/core/retry.ts +++ b/src/core/retry.ts @@ -26,7 +26,7 @@ export async function withRetry( let attempt = 0; while (true) { try { - return fn(); + return await fn(); } catch (err) { if (!(err instanceof Error)) throw err; if ((attempt++ >= attempts) || (retry && !retry(err))) {