Skip to content

Commit c1e4f8a

Browse files
committed
Add retry logic to downloadWithProgress for transient network failures
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.
1 parent 23bb687 commit c1e4f8a

1 file changed

Lines changed: 53 additions & 32 deletions

File tree

src/core/download.ts

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { writeAll } from "io/write-all";
88
import { progressBar } from "./console.ts";
9+
import { withRetry } from "./retry.ts";
910

1011
export interface DownloadError extends Error {
1112
statusCode: number;
@@ -17,42 +18,62 @@ export async function downloadWithProgress(
1718
msg: string,
1819
toFile: string,
1920
) {
20-
// Fetch the data
21-
const response = await (typeof url === "string"
22-
? fetch(
23-
url,
24-
{
25-
redirect: "follow",
26-
},
27-
)
28-
: url);
21+
await withRetry(async () => {
22+
// Fetch the data
23+
const response = await (typeof url === "string"
24+
? fetch(
25+
url,
26+
{
27+
redirect: "follow",
28+
},
29+
)
30+
: url);
2931

30-
// Write the data to a file
31-
if (response.status === 200 && response.body) {
32-
const pkgFile = await Deno.open(toFile, { create: true, write: true });
32+
// Write the data to a file
33+
if (response.status === 200 && response.body) {
34+
const pkgFile = await Deno.open(
35+
toFile,
36+
{ create: true, write: true, truncate: true },
37+
);
3338

34-
const contentLength =
35-
(response.headers.get("content-length") || 0) as number;
36-
const contentLengthMb = contentLength / 1024 / 1024;
39+
const contentLength =
40+
(response.headers.get("content-length") || 0) as number;
41+
const contentLengthMb = contentLength / 1024 / 1024;
3742

38-
const prog = progressBar(contentLengthMb, msg);
43+
const prog = progressBar(contentLengthMb, msg);
3944

40-
let totalLength = 0;
41-
for await (const chunk of response.body) {
42-
await writeAll(pkgFile, chunk);
43-
totalLength = totalLength + chunk.length;
44-
if (contentLength > 0) {
45-
prog.update(
46-
totalLength / 1024 / 1024,
47-
`${(totalLength / 1024 / 1024).toFixed(1)}MB`,
48-
);
45+
try {
46+
let totalLength = 0;
47+
for await (const chunk of response.body) {
48+
await writeAll(pkgFile, chunk);
49+
totalLength = totalLength + chunk.length;
50+
if (contentLength > 0) {
51+
prog.update(
52+
totalLength / 1024 / 1024,
53+
`${(totalLength / 1024 / 1024).toFixed(1)}MB`,
54+
);
55+
}
56+
}
57+
prog.complete();
58+
} finally {
59+
pkgFile.close();
4960
}
61+
} else {
62+
throw new Error(
63+
`download failed (HTTP status ${response.status} - ${response.statusText})`,
64+
);
5065
}
51-
prog.complete();
52-
pkgFile.close();
53-
} else {
54-
throw new Error(
55-
`download failed (HTTP status ${response.status} - ${response.statusText})`,
56-
);
57-
}
66+
}, {
67+
attempts: 3,
68+
minWait: 2000,
69+
maxWait: 10000,
70+
retry: (err: Error) => {
71+
// Don't retry HTTP status errors (4xx, 5xx) — they're deterministic
72+
if (err.message.startsWith("download failed (HTTP status")) {
73+
return false;
74+
}
75+
// Retry network errors (connection reset, timeout, body read errors)
76+
return true;
77+
},
78+
});
5879
}

0 commit comments

Comments
 (0)