From b18a0ee6ebdced3ee3fece4c54114cbd7abac800 Mon Sep 17 00:00:00 2001 From: cmorten Date: Sun, 16 Aug 2026 21:22:06 +0100 Subject: [PATCH] feat: implement backoff retry for install subcommand downloads --- src/commands/install/download-asset.ts | 78 ++++++++++++++++++++------ 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/commands/install/download-asset.ts b/src/commands/install/download-asset.ts index 2c0a331..1ad66a9 100644 --- a/src/commands/install/download-asset.ts +++ b/src/commands/install/download-asset.ts @@ -1,4 +1,4 @@ -import { createWriteStream } from "node:fs"; +import { createWriteStream, rmSync } from "node:fs"; import { pipeline } from "node:stream/promises"; import { EnvHttpProxyAgent, fetch } from "undici"; import type { Asset } from "./types"; @@ -6,27 +6,73 @@ import { ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET } from "../../errors"; const dispatcher = new EnvHttpProxyAgent(); +const DOWNLOAD_BACKOFFS = [500, 1000, 2000, 5000, 8000]; + +function isRetryableDownloadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const code = "code" in error ? error.code : undefined; + + return [ + "ECONNRESET", + "ECONNREFUSED", + "ECONNABORTED", + "ETIMEDOUT", + "EAI_AGAIN", + ].includes(code as string); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export async function downloadAsset( asset: Asset, source: string, destination: string, ): Promise { - const response = await fetch(source, { - dispatcher, - }); + let lastError: unknown; - if (!response.ok || !response.body) { - throw new Error( - `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, - ); - } + for (let attempt = 0; attempt <= DOWNLOAD_BACKOFFS.length; attempt++) { + try { + const response = await fetch(source, { + dispatcher, + }); - try { - await pipeline(response.body, createWriteStream(destination)); - } catch (cause) { - throw new Error( - `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, - { cause }, - ); + if (!response.ok || !response.body) { + if (response.status >= 500 && attempt < DOWNLOAD_BACKOFFS.length) { + await delay(DOWNLOAD_BACKOFFS[attempt]); + + continue; + } + + throw new Error( + `${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset} (${response.status} ${response.statusText})`, + ); + } + + await pipeline(response.body, createWriteStream(destination)); + + return; + } catch (cause) { + lastError = cause; + + rmSync(destination, { force: true }); + + if ( + !isRetryableDownloadError(cause) || + attempt >= DOWNLOAD_BACKOFFS.length + ) { + break; + } + + await delay(DOWNLOAD_BACKOFFS[attempt]); + } } + + throw new Error(`${ERR_INSTALL_FAILED_TO_DOWNLOAD_ASSET}: ${asset.asset}`, { + cause: lastError, + }); }