Validate and retry manifest fetch to prevent silent failures (#1332)

* validate and retry manifest fetch

* Refactor error handling in isRateLimitError function for improved clarity
This commit is contained in:
Priya Gupta
2026-07-15 04:32:48 +05:30
committed by GitHub
parent c7092773a3
commit 54baeea5b3
3 changed files with 200 additions and 22 deletions
+60
View File
@@ -31,6 +31,10 @@ describe('getManifest', () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
afterEach(() => {
jest.useRealTimers();
});
it('should return manifest from repo', async () => { it('should return manifest from repo', async () => {
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
const manifest = await getManifest(); const manifest = await getManifest();
@@ -38,14 +42,70 @@ describe('getManifest', () => {
}); });
it('should return manifest from URL if repo fetch fails', async () => { it('should return manifest from URL if repo fetch fails', async () => {
jest.useFakeTimers();
(tc.getManifestFromRepo as jest.Mock).mockRejectedValue( (tc.getManifestFromRepo as jest.Mock).mockRejectedValue(
new Error('Fetch failed') new Error('Fetch failed')
); );
(httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({ (httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({
result: mockManifest result: mockManifest
}); });
const promise = getManifest();
await jest.runAllTimersAsync();
const manifest = await promise;
expect(manifest).toEqual(mockManifest);
});
it('should fall back to URL if repo returns a truncated/empty manifest', async () => {
jest.useFakeTimers();
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue([]);
(httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({
result: mockManifest
});
const promise = getManifest();
await jest.runAllTimersAsync();
const manifest = await promise;
expect(manifest).toEqual(mockManifest);
});
it('should retry on a transient invalid manifest and then succeed', async () => {
jest.useFakeTimers();
(tc.getManifestFromRepo as jest.Mock)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(mockManifest);
const promise = getManifest();
await jest.runAllTimersAsync();
const manifest = await promise;
expect(manifest).toEqual(mockManifest);
expect(tc.getManifestFromRepo as jest.Mock).toHaveBeenCalledTimes(2);
});
it('should fail loudly when the manifest is truncated/empty on every source', async () => {
jest.useFakeTimers();
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue([]);
(httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({
result: []
});
const promise = getManifest();
// Prevent unhandled rejection before timers advance.
const catchPromise = promise.catch(() => {});
await jest.runAllTimersAsync();
await catchPromise;
await expect(promise).rejects.toThrow(
'Failed to fetch the Python versions manifest'
);
});
it('should not retry the API on a rate-limit error and fall back to URL immediately', async () => {
const rateLimitError = Object.assign(new Error('API rate limit exceeded'), {
httpStatusCode: 403
});
(tc.getManifestFromRepo as jest.Mock).mockRejectedValue(rateLimitError);
(httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({
result: mockManifest
});
const manifest = await getManifest(); const manifest = await getManifest();
expect(manifest).toEqual(mockManifest); expect(manifest).toEqual(mockManifest);
expect(tc.getManifestFromRepo as jest.Mock).toHaveBeenCalledTimes(1);
}); });
}); });
+57 -9
View File
@@ -55685,6 +55685,8 @@ function findRhelRelease(semanticVersionSpec, architecture, manifest, osVersion)
} }
return undefined; return undefined;
} }
const MANIFEST_FETCH_MAX_ATTEMPTS = 3;
const MANIFEST_FETCH_RETRY_BASE_DELAY_MS = 1000;
async function findReleaseFromManifest(semanticVersionSpec, architecture, manifest) { async function findReleaseFromManifest(semanticVersionSpec, architecture, manifest) {
if (!manifest) { if (!manifest) {
manifest = await getManifest(); manifest = await getManifest();
@@ -55712,15 +55714,54 @@ function isIToolRelease(obj) {
typeof file.arch === 'string' && typeof file.arch === 'string' &&
typeof file.download_url === 'string')); typeof file.download_url === 'string'));
} }
// Rejects empty or truncated manifest responses.
function isValidManifest(manifest) {
return (Array.isArray(manifest) &&
manifest.length > 0 &&
manifest.every(isIToolRelease));
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// HTTP 403/429 from http-client (`statusCode`) or tool-cache (`httpStatusCode`).
function isRateLimitError(err) {
const e = err;
const status = e?.httpStatusCode ?? e?.statusCode;
return status === 403 || status === 429;
}
// Fetches and validates a manifest, retrying transient failures with backoff.
async function fetchValidManifest(source, fetcher) {
let lastError;
let attempts = 0;
for (let attempt = 1; attempt <= MANIFEST_FETCH_MAX_ATTEMPTS; attempt++) {
attempts = attempt;
try {
const manifest = await fetcher();
if (isValidManifest(manifest)) {
return manifest;
}
throw new Error(`The manifest fetched from ${source} is empty, truncated, or does not contain any valid tool release entries.`);
}
catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
core.debug(`Attempt ${attempt}/${MANIFEST_FETCH_MAX_ATTEMPTS} to fetch the manifest from ${source} failed: ${lastError.message}`);
// Rate limits won't clear within the backoff window; fall back instead.
if (isRateLimitError(err)) {
core.debug(`${source} is rate-limited; skipping retries for this source.`);
break;
}
if (attempt < MANIFEST_FETCH_MAX_ATTEMPTS) {
const delay = MANIFEST_FETCH_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
core.debug(`Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`);
}
async function getManifest() { async function getManifest() {
try { try {
const repoManifest = await getManifestFromRepo(); return await fetchValidManifest('the GitHub API', getManifestFromRepo);
if (Array.isArray(repoManifest) &&
repoManifest.length &&
repoManifest.every(isIToolRelease)) {
return repoManifest;
}
throw new Error('The repository manifest is invalid or does not include any valid tool release (IToolRelease) entries.');
} }
catch (err) { catch (err) {
core.debug('Fetching the manifest via the API failed.'); core.debug('Fetching the manifest via the API failed.');
@@ -55728,10 +55769,17 @@ async function getManifest() {
core.debug(err.message); core.debug(err.message);
} }
else { else {
core.error('An unexpected error occurred while fetching the manifest.'); core.debug('An unexpected error occurred while fetching the manifest.');
} }
} }
return await getManifestFromURL(); try {
return await fetchValidManifest('the raw URL', getManifestFromURL);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Fail loudly so the action doesn't exit 0 without installing Python.
throw new Error(`Failed to fetch the Python versions manifest. The response was empty, truncated, or invalid, and all retries were exhausted. ${message}`);
}
} }
function getManifestFromRepo() { function getManifestFromRepo() {
core.debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`); core.debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`);
+81 -11
View File
@@ -80,6 +80,9 @@ function findRhelRelease(
return undefined; return undefined;
} }
const MANIFEST_FETCH_MAX_ATTEMPTS = 3;
const MANIFEST_FETCH_RETRY_BASE_DELAY_MS = 1000;
export async function findReleaseFromManifest( export async function findReleaseFromManifest(
semanticVersionSpec: string, semanticVersionSpec: string,
architecture: string, architecture: string,
@@ -133,28 +136,95 @@ function isIToolRelease(obj: any): obj is IToolRelease {
); );
} }
export async function getManifest(): Promise<tc.IToolRelease[]> { // Rejects empty or truncated manifest responses.
function isValidManifest(manifest: unknown): manifest is tc.IToolRelease[] {
return (
Array.isArray(manifest) &&
manifest.length > 0 &&
manifest.every(isIToolRelease)
);
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// HTTP 403/429 from http-client (`statusCode`) or tool-cache (`httpStatusCode`).
function isRateLimitError(err: unknown): boolean {
const e = err as
| {httpStatusCode?: number; statusCode?: number}
| null
| undefined;
const status = e?.httpStatusCode ?? e?.statusCode;
return status === 403 || status === 429;
}
// Fetches and validates a manifest, retrying transient failures with backoff.
async function fetchValidManifest(
source: string,
fetcher: () => Promise<tc.IToolRelease[]>
): Promise<tc.IToolRelease[]> {
let lastError: Error | undefined;
let attempts = 0;
for (let attempt = 1; attempt <= MANIFEST_FETCH_MAX_ATTEMPTS; attempt++) {
attempts = attempt;
try { try {
const repoManifest = await getManifestFromRepo(); const manifest = await fetcher();
if ( if (isValidManifest(manifest)) {
Array.isArray(repoManifest) && return manifest;
repoManifest.length &&
repoManifest.every(isIToolRelease)
) {
return repoManifest;
} }
throw new Error( throw new Error(
'The repository manifest is invalid or does not include any valid tool release (IToolRelease) entries.' `The manifest fetched from ${source} is empty, truncated, or does not contain any valid tool release entries.`
); );
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
core.debug(
`Attempt ${attempt}/${MANIFEST_FETCH_MAX_ATTEMPTS} to fetch the manifest from ${source} failed: ${lastError.message}`
);
// Rate limits won't clear within the backoff window; fall back instead.
if (isRateLimitError(err)) {
core.debug(
`${source} is rate-limited; skipping retries for this source.`
);
break;
}
if (attempt < MANIFEST_FETCH_MAX_ATTEMPTS) {
const delay = MANIFEST_FETCH_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
core.debug(`Retrying in ${delay}ms...`);
await sleep(delay);
}
}
}
throw new Error(
`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`
);
}
export async function getManifest(): Promise<tc.IToolRelease[]> {
try {
return await fetchValidManifest('the GitHub API', getManifestFromRepo);
} catch (err) { } catch (err) {
core.debug('Fetching the manifest via the API failed.'); core.debug('Fetching the manifest via the API failed.');
if (err instanceof Error) { if (err instanceof Error) {
core.debug(err.message); core.debug(err.message);
} else { } else {
core.error('An unexpected error occurred while fetching the manifest.'); core.debug('An unexpected error occurred while fetching the manifest.');
} }
} }
return await getManifestFromURL();
try {
return await fetchValidManifest('the raw URL', getManifestFromURL);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Fail loudly so the action doesn't exit 0 without installing Python.
throw new Error(
`Failed to fetch the Python versions manifest. The response was empty, truncated, or invalid, and all retries were exhausted. ${message}`
);
}
} }
export function getManifestFromRepo(): Promise<tc.IToolRelease[]> { export function getManifestFromRepo(): Promise<tc.IToolRelease[]> {