|
1 | | -import { createWriteStream } from "node:fs"; |
| 1 | +import { createWriteStream, rmSync } from "node:fs"; |
2 | 2 | import { pipeline } from "node:stream/promises"; |
3 | 3 | import { EnvHttpProxyAgent, fetch } from "undici"; |
4 | 4 | import type { Asset } from "./types"; |
5 | 5 | import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET } from "../../errors"; |
6 | 6 |
|
7 | 7 | const dispatcher = new EnvHttpProxyAgent(); |
8 | 8 |
|
| 9 | +const DOWNLOAD_BACKOFFS = [500, 1000, 2000, 5000, 8000]; |
| 10 | + |
| 11 | +function isRetryableDownloadError(error: unknown): boolean { |
| 12 | + if (!(error instanceof Error)) { |
| 13 | + return false; |
| 14 | + } |
| 15 | + |
| 16 | + const code = "code" in error ? error.code : undefined; |
| 17 | + |
| 18 | + return [ |
| 19 | + "ECONNRESET", |
| 20 | + "ECONNREFUSED", |
| 21 | + "ECONNABORTED", |
| 22 | + "ETIMEDOUT", |
| 23 | + "EAI_AGAIN", |
| 24 | + ].includes(code as string); |
| 25 | +} |
| 26 | + |
| 27 | +function delay(ms: number): Promise<void> { |
| 28 | + return new Promise((resolve) => setTimeout(resolve, ms)); |
| 29 | +} |
| 30 | + |
9 | 31 | export async function downloadAsset( |
10 | 32 | asset: Asset, |
11 | 33 | source: string, |
12 | 34 | destination: string, |
13 | 35 | ): Promise<void> { |
14 | | - const response = await fetch(source, { |
15 | | - dispatcher, |
16 | | - }); |
| 36 | + let lastError: unknown; |
17 | 37 |
|
18 | | - if (!response.ok || !response.body) { |
19 | | - throw new Error( |
20 | | - `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, |
21 | | - ); |
22 | | - } |
| 38 | + for (let attempt = 0; attempt <= DOWNLOAD_BACKOFFS.length; attempt++) { |
| 39 | + try { |
| 40 | + const response = await fetch(source, { |
| 41 | + dispatcher, |
| 42 | + }); |
23 | 43 |
|
24 | | - try { |
25 | | - await pipeline(response.body, createWriteStream(destination)); |
26 | | - } catch (cause) { |
27 | | - throw new Error( |
28 | | - `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, |
29 | | - { cause }, |
30 | | - ); |
| 44 | + if (!response.ok || !response.body) { |
| 45 | + if (response.status >= 500 && attempt < DOWNLOAD_BACKOFFS.length) { |
| 46 | + await delay(DOWNLOAD_BACKOFFS[attempt]); |
| 47 | + |
| 48 | + continue; |
| 49 | + } |
| 50 | + |
| 51 | + throw new Error( |
| 52 | + `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, |
| 53 | + ); |
| 54 | + } |
| 55 | + |
| 56 | + await pipeline(response.body, createWriteStream(destination)); |
| 57 | + |
| 58 | + return; |
| 59 | + } catch (cause) { |
| 60 | + lastError = cause; |
| 61 | + |
| 62 | + rmSync(destination, { force: true }); |
| 63 | + |
| 64 | + if ( |
| 65 | + !isRetryableDownloadError(cause) || |
| 66 | + attempt >= DOWNLOAD_BACKOFFS.length |
| 67 | + ) { |
| 68 | + break; |
| 69 | + } |
| 70 | + |
| 71 | + await delay(DOWNLOAD_BACKOFFS[attempt]); |
| 72 | + } |
31 | 73 | } |
| 74 | + |
| 75 | + throw new Error(`${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset}`, { |
| 76 | + cause: lastError, |
| 77 | + }); |
32 | 78 | } |
0 commit comments