Validate and retry manifest fetch to prevent silent failures (#1332) · actions/setup-python@54baeea · GitHub
Skip to content

Commit 54baeea

Browse files
Validate and retry manifest fetch to prevent silent failures (#1332)
* validate and retry manifest fetch * Refactor error handling in isRateLimitError function for improved clarity
1 parent c709277 commit 54baeea

3 files changed

Lines changed: 200 additions & 22 deletions

File tree

__tests__/install-python.test.ts

Lines changed: 60 additions & 0 deletions

dist/setup/index.js

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55685,6 +55685,8 @@ function findRhelRelease(semanticVersionSpec, architecture, manifest, osVersion)
5568555685
}
5568655686
return undefined;
5568755687
}
55688+
const MANIFEST_FETCH_MAX_ATTEMPTS = 3;
55689+
const MANIFEST_FETCH_RETRY_BASE_DELAY_MS = 1000;
5568855690
async function findReleaseFromManifest(semanticVersionSpec, architecture, manifest) {
5568955691
if (!manifest) {
5569055692
manifest = await getManifest();
@@ -55712,26 +55714,72 @@ function isIToolRelease(obj) {
5571255714
typeof file.arch === 'string' &&
5571355715
typeof file.download_url === 'string'));
5571455716
}
55717+
// Rejects empty or truncated manifest responses.
55718+
function isValidManifest(manifest) {
55719+
return (Array.isArray(manifest) &&
55720+
manifest.length > 0 &&
55721+
manifest.every(isIToolRelease));
55722+
}
55723+
function sleep(ms) {
55724+
return new Promise(resolve => setTimeout(resolve, ms));
55725+
}
55726+
// HTTP 403/429 from http-client (`statusCode`) or tool-cache (`httpStatusCode`).
55727+
function isRateLimitError(err) {
55728+
const e = err;
55729+
const status = e?.httpStatusCode ?? e?.statusCode;
55730+
return status === 403 || status === 429;
55731+
}
55732+
// Fetches and validates a manifest, retrying transient failures with backoff.
55733+
async function fetchValidManifest(source, fetcher) {
55734+
let lastError;
55735+
let attempts = 0;
55736+
for (let attempt = 1; attempt <= MANIFEST_FETCH_MAX_ATTEMPTS; attempt++) {
55737+
attempts = attempt;
55738+
try {
55739+
const manifest = await fetcher();
55740+
if (isValidManifest(manifest)) {
55741+
return manifest;
55742+
}
55743+
throw new Error(`The manifest fetched from ${source} is empty, truncated, or does not contain any valid tool release entries.`);
55744+
}
55745+
catch (err) {
55746+
lastError = err instanceof Error ? err : new Error(String(err));
55747+
core.debug(`Attempt ${attempt}/${MANIFEST_FETCH_MAX_ATTEMPTS} to fetch the manifest from ${source} failed: ${lastError.message}`);
55748+
// Rate limits won't clear within the backoff window; fall back instead.
55749+
if (isRateLimitError(err)) {
55750+
core.debug(`${source} is rate-limited; skipping retries for this source.`);
55751+
break;
55752+
}
55753+
if (attempt < MANIFEST_FETCH_MAX_ATTEMPTS) {
55754+
const delay = MANIFEST_FETCH_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
55755+
core.debug(`Retrying in ${delay}ms...`);
55756+
await sleep(delay);
55757+
}
55758+
}
55759+
}
55760+
throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`);
55761+
}
5571555762
async function getManifest() {
5571655763
try {
55717-
const repoManifest = await getManifestFromRepo();
55718-
if (Array.isArray(repoManifest) &&
55719-
repoManifest.length &&
55720-
repoManifest.every(isIToolRelease)) {
55721-
return repoManifest;
55722-
}
55723-
throw new Error('The repository manifest is invalid or does not include any valid tool release (IToolRelease) entries.');
55764+
return await fetchValidManifest('the GitHub API', getManifestFromRepo);
5572455765
}
5572555766
catch (err) {
5572655767
core.debug('Fetching the manifest via the API failed.');
5572755768
if (err instanceof Error) {
5572855769
core.debug(err.message);
5572955770
}
5573055771
else {
55731-
core.error('An unexpected error occurred while fetching the manifest.');
55772+
core.debug('An unexpected error occurred while fetching the manifest.');
5573255773
}
5573355774
}
55734-
return await getManifestFromURL();
55775+
try {
55776+
return await fetchValidManifest('the raw URL', getManifestFromURL);
55777+
}
55778+
catch (err) {
55779+
const message = err instanceof Error ? err.message : String(err);
55780+
// Fail loudly so the action doesn't exit 0 without installing Python.
55781+
throw new Error(`Failed to fetch the Python versions manifest. The response was empty, truncated, or invalid, and all retries were exhausted. ${message}`);
55782+
}
5573555783
}
5573655784
function getManifestFromRepo() {
5573755785
core.debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`);

src/install-python.ts

Lines changed: 83 additions & 13 deletions

0 commit comments

Comments
 (0)