From c1e4f8a24a98fed53fc473050c3da0f63e8be08e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 17:29:36 +0100 Subject: [PATCH 1/5] Add retry logic to downloadWithProgress for transient network failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the fetch+stream download in withRetry (3 attempts, 2-10s jittered backoff). Only retries on network errors (connection reset, body read failures), not on HTTP status errors which are deterministic. This protects all `quarto install` commands (TinyTeX, Chrome Headless Shell, VeraPDF) from transient CDN failures — both in CI and for end users on flaky connections. --- src/core/download.ts | 85 +++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/src/core/download.ts b/src/core/download.ts index 88608169f3c..2f3bdee4dee 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; + }, + }); } From 847f9b3cdf65f14ba6584d97180c5becb6541caf Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 17:30:17 +0100 Subject: [PATCH 2/5] Add retry loop to CI tool install steps Wraps TinyTeX and Chrome Headless Shell install steps in a 3-attempt retry loop with 15s delay. Uses GitHub Actions annotations (::warning:: and ::error::) for visibility. Defense-in-depth alongside the internal download retry added to downloadWithProgress. --- .github/workflows/test-install.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index c9c4d0d05a4..a28a4835ef4 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -40,11 +40,27 @@ 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 + echo "::warning::Attempt $attempt failed, retrying in 15s..." + sleep 15 + 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 + echo "::warning::Attempt $attempt failed, retrying in 15s..." + sleep 15 + done + echo "::error::Chrome Headless Shell install failed after 3 attempts" + exit 1 - name: Verify tools with quarto check run: | From 03630e649890d8d009198026d7a075d27eaa4bf8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 17:31:44 +0100 Subject: [PATCH 3/5] Add src/core/download.ts to test-install workflow path triggers Changes to the download logic should trigger the install integration tests since all tool installs go through downloadWithProgress. --- .github/workflows/test-install.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index a28a4835ef4..af6caa87f0f 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -10,10 +10,12 @@ on: - "v1.*" paths: - "src/tools/**" + - "src/core/download.ts" - ".github/workflows/test-install.yml" pull_request: paths: - "src/tools/**" + - "src/core/download.ts" - ".github/workflows/test-install.yml" schedule: # Weekly Monday 9am UTC — detect upstream CDN/API breakage From da834b8544aa97e3a4efd250e1cc52a1161fd54b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 19:41:27 +0100 Subject: [PATCH 4/5] Fix withRetry to actually catch async rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return fn()` inside an async try block returns the promise directly without awaiting — rejected promises bypass the catch block entirely. Changed to `return await fn()` so async failures are caught and retried. Without this fix, withRetry never retried async callbacks (including downloadWithProgress and Netlify file uploads). --- src/core/retry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/retry.ts b/src/core/retry.ts index 017560a8b6d..0b89d133242 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))) { From de3c7a6863d7e320d4e7ad859c71040e2e19cdf5 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 20:40:05 +0100 Subject: [PATCH 5/5] Fix CI retry loop off-by-one and add retry.ts to path triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip sleep/warning on final attempt — no retry follows. Also add src/core/retry.ts to workflow path triggers since retry behavior directly affects tool installs. --- .github/workflows/test-install.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index af6caa87f0f..c9885e1dc79 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -11,11 +11,13 @@ on: 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 @@ -46,8 +48,10 @@ jobs: if quarto install tinytex; then exit 0 fi - echo "::warning::Attempt $attempt failed, retrying in 15s..." - sleep 15 + 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 @@ -58,8 +62,10 @@ jobs: if quarto install chrome-headless-shell --no-prompt; then exit 0 fi - echo "::warning::Attempt $attempt failed, retrying in 15s..." - sleep 15 + 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