diff --git a/dist/setup/index.js b/dist/setup/index.js index f8f14af58..fa990fdaa 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -97291,6 +97291,50 @@ const fs_1 = __importDefault(__nccwpck_require__(9896)); const utils_1 = __nccwpck_require__(1798); const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; +/* + * Improved Generic retry wrapper with: + * - Retry-After support + * - Jitter + * - Network error retries (ECONNRESET, ETIMEDOUT, etc.) + */ +async function retry(fn, retries = 4, baseDelay = 2000) { + let lastErr; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fn(); + } + catch (err) { + lastErr = err; + const status = err?.statusCode || + err?.httpStatusCode || + err?.response?.status || + err?.response?.message?.statusCode; + const retryAfter = err?.response?.headers?.['retry-after'] || + err?.response?.headers?.['Retry-After']; + const networkRetryable = err?.code && + ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND'].includes(err.code); + const statusRetryable = !status || status >= 500 || status === 429 || status === 403; + const retryable = statusRetryable || networkRetryable; + if (!retryable || attempt === retries) { + core.warning(`Attempt ${attempt} failed with non-retryable error: ${err.message}`); + break; + } + // Use Retry-After header if available + let delay = baseDelay * Math.pow(2, attempt - 1); + if (retryAfter) { + delay = parseInt(retryAfter, 10) * 1000; + } + // Add jitter + delay += Math.floor(Math.random() * 1000); + core.warning(`Attempt ${attempt} failed: ${err.message}. ` + + `Status: ${status}. ` + + (retryAfter ? `Retry-After: ${retryAfter}s. ` : '') + + `Retrying in ${delay}ms...`); + await new Promise(res => setTimeout(res, delay)); + } + } + throw lastErr; +} async function installGraalPy(graalpyVersion, architecture, allowPreReleases, releases) { let downloadDir; releases = releases ?? (await getAvailableGraalPyVersions()); @@ -97299,7 +97343,6 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re } let releaseData = findRelease(releases, graalpyVersion, architecture, false); if (allowPreReleases && (!releaseData || !releaseData.foundAsset)) { - // check for pre-release core.info([ `Stable GraalPy version ${graalpyVersion} with arch ${architecture} not found`, `Trying pre-release versions` @@ -97313,7 +97356,8 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re const downloadUrl = `${foundAsset.browser_download_url}`; core.info(`Downloading GraalPy from "${downloadUrl}" ...`); try { - const graalpyPath = await tc.downloadTool(downloadUrl, undefined, AUTH); + // ⭐ Wrapped in improved retry + const graalpyPath = await retry(() => tc.downloadTool(downloadUrl, undefined, AUTH), 4, 2000); core.info('Extracting downloaded archive...'); if (utils_1.IS_WINDOWS) { downloadDir = await tc.extractZip(graalpyPath); @@ -97321,8 +97365,6 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re else { downloadDir = await tc.extractTar(graalpyPath); } - // root folder in archive can have unpredictable name so just take the first folder - // downloadDir is unique folder under TEMP and can't contain any other folders const archiveName = fs_1.default.readdirSync(downloadDir)[0]; const toolDir = path.join(downloadDir, archiveName); let installDir = toolDir; @@ -97336,10 +97378,9 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re } catch (err) { if (err instanceof Error) { - // Rate limit? if (err instanceof tc.HTTPError && (err.httpStatusCode === 403 || err.httpStatusCode === 429)) { - core.info(`Received HTTP status code ${err.httpStatusCode}. This usually indicates the rate limit has been exceeded`); + core.info(`Received HTTP status code ${err.httpStatusCode}. This usually indicates the rate limit has been exceeded`); } else { core.info(err.message); @@ -97358,12 +97399,12 @@ async function getAvailableGraalPyVersions() { headers.authorization = AUTH; } /* - Get releases first. - */ + * Stable releases with retry + */ let url = 'https://api.github.com/repos/oracle/graalpython/releases'; const result = []; do { - const response = await http.getJson(url, headers); + const response = await retry(() => http.getJson(url, headers), 4, 1500); if (!response.result) { throw new Error(`Unable to retrieve the list of available GraalPy versions from '${url}'`); } @@ -97371,12 +97412,12 @@ async function getAvailableGraalPyVersions() { url = (0, utils_1.getNextPageUrl)(response); } while (url); /* - Add pre-release builds. - */ + * Pre-release builds with retry + */ url = 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; do { - const response = await http.getJson(url, headers); + const response = await retry(() => http.getJson(url, headers), 4, 1500); if (!response.result) { throw new Error(`Unable to retrieve the list of available GraalPy versions from '${url}'`); } @@ -97455,9 +97496,6 @@ function findAsset(item, architecture, platform) { const graalpyExt = platform == 'win32' ? 'zip' : 'tar.gz'; const found = item.assets.filter(file => file.name.startsWith('graalpy') && file.name.endsWith(`-${graalpyPlatform}-${graalpyArch}.${graalpyExt}`)); - /* - In the future there could be more variants of GraalPy for a single release. Pick the shortest name, that one is the most likely to be the primary variant. - */ found.sort((f1, f2) => f1.name.length - f2.name.length); return found[0]; } diff --git a/src/install-graalpy.ts b/src/install-graalpy.ts index b1029539c..6518a1c8b 100644 --- a/src/install-graalpy.ts +++ b/src/install-graalpy.ts @@ -21,6 +21,76 @@ import { const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; +/* + * Improved Generic retry wrapper with: + * - Retry-After support + * - Jitter + * - Network error retries (ECONNRESET, ETIMEDOUT, etc.) + */ +async function retry( + fn: () => Promise, + retries = 4, + baseDelay = 2000 +): Promise { + let lastErr; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err: any) { + lastErr = err; + + const status = + err?.statusCode || + err?.httpStatusCode || + err?.response?.status || + err?.response?.message?.statusCode; + + const retryAfter = + err?.response?.headers?.['retry-after'] || + err?.response?.headers?.['Retry-After']; + + const networkRetryable = + err?.code && + ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND'].includes( + err.code + ); + + const statusRetryable = + !status || status >= 500 || status === 429 || status === 403; + + const retryable = statusRetryable || networkRetryable; + + if (!retryable || attempt === retries) { + core.warning( + `Attempt ${attempt} failed with non-retryable error: ${err.message}` + ); + break; + } + + // Use Retry-After header if available + let delay = baseDelay * Math.pow(2, attempt - 1); + if (retryAfter) { + delay = parseInt(retryAfter, 10) * 1000; + } + + // Add jitter + delay += Math.floor(Math.random() * 1000); + + core.warning( + `Attempt ${attempt} failed: ${err.message}. ` + + `Status: ${status}. ` + + (retryAfter ? `Retry-After: ${retryAfter}s. ` : '') + + `Retrying in ${delay}ms...` + ); + + await new Promise(res => setTimeout(res, delay)); + } + } + + throw lastErr; +} + export async function installGraalPy( graalpyVersion: string, architecture: string, @@ -38,7 +108,6 @@ export async function installGraalPy( let releaseData = findRelease(releases, graalpyVersion, architecture, false); if (allowPreReleases && (!releaseData || !releaseData.foundAsset)) { - // check for pre-release core.info( [ `Stable GraalPy version ${graalpyVersion} with arch ${architecture} not found`, @@ -60,7 +129,12 @@ export async function installGraalPy( core.info(`Downloading GraalPy from "${downloadUrl}" ...`); try { - const graalpyPath = await tc.downloadTool(downloadUrl, undefined, AUTH); + // ⭐ Wrapped in improved retry + const graalpyPath = await retry( + () => tc.downloadTool(downloadUrl, undefined, AUTH), + 4, + 2000 + ); core.info('Extracting downloaded archive...'); if (IS_WINDOWS) { @@ -69,8 +143,6 @@ export async function installGraalPy( downloadDir = await tc.extractTar(graalpyPath); } - // root folder in archive can have unpredictable name so just take the first folder - // downloadDir is unique folder under TEMP and can't contain any other folders const archiveName = fs.readdirSync(downloadDir)[0]; const toolDir = path.join(downloadDir, archiveName); @@ -91,13 +163,12 @@ export async function installGraalPy( return {installDir, resolvedGraalPyVersion}; } catch (err) { if (err instanceof Error) { - // Rate limit? if ( err instanceof tc.HTTPError && (err.httpStatusCode === 403 || err.httpStatusCode === 429) ) { core.info( - `Received HTTP status code ${err.httpStatusCode}. This usually indicates the rate limit has been exceeded` + `Received HTTP status code ${err.httpStatusCode}. This usually indicates the rate limit has been exceeded` ); } else { core.info(err.message); @@ -119,14 +190,18 @@ export async function getAvailableGraalPyVersions() { } /* - Get releases first. - */ + * Stable releases with retry + */ let url: string | null = 'https://api.github.com/repos/oracle/graalpython/releases'; const result: IGraalPyManifestRelease[] = []; do { - const response: ifm.TypedResponse = - await http.getJson(url, headers); + const response: ifm.TypedResponse = await retry( + () => http.getJson(url!, headers), + 4, + 1500 + ); + if (!response.result) { throw new Error( `Unable to retrieve the list of available GraalPy versions from '${url}'` @@ -137,13 +212,17 @@ export async function getAvailableGraalPyVersions() { } while (url); /* - Add pre-release builds. - */ + * Pre-release builds with retry + */ url = 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; do { - const response: ifm.TypedResponse = - await http.getJson(url, headers); + const response: ifm.TypedResponse = await retry( + () => http.getJson(url!, headers), + 4, + 1500 + ); + if (!response.result) { throw new Error( `Unable to retrieve the list of available GraalPy versions from '${url}'` @@ -281,9 +360,7 @@ export function findAsset( file.name.startsWith('graalpy') && file.name.endsWith(`-${graalpyPlatform}-${graalpyArch}.${graalpyExt}`) ); - /* - In the future there could be more variants of GraalPy for a single release. Pick the shortest name, that one is the most likely to be the primary variant. - */ + found.sort((f1, f2) => f1.name.length - f2.name.length); return found[0]; }