Tolerate transient manifest timeouts (#1016)

Transient timeout fetching manifests have increased significantly
recently, especially with private runners.

```
Fetching manifest data from https://raw.githubusercontent.com/astral-sh/versions/main/v1/uv.ndjson ...
Error: The operation was aborted due to timeout
```

Retry transient manifest network failures up to three times with a
progressive backoff (not exponential), keeping the total wait bounded
while making setup resilient to short network blips.

Co-authored-by: Raymond <arguile-@users.noreply.github.com>
This commit is contained in:
Raymond
2026-08-13 12:34:10 -04:00
committed by GitHub
parent ae3b92d1bd
commit d73a0cab66
4 changed files with 100 additions and 7 deletions
+41 -1
View File
@@ -1,4 +1,11 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals";
// biome-ignore lint/suspicious/noExplicitAny: Mock requires flexible typing in tests.
const mockFetch = jest.fn<any>();
@@ -13,6 +20,7 @@ jest.unstable_mockModule("../../src/utils/fetch", () => ({
}));
const {
MANIFEST_FETCH_ATTEMPTS,
clearManifestCache,
fetchManifest,
getAllVersions,
@@ -73,6 +81,10 @@ describe("manifest", () => {
mockFetch.mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
describe("fetchManifest", () => {
it("fetches and parses manifest data", async () => {
mockFetch.mockResolvedValue(
@@ -86,6 +98,34 @@ describe("manifest", () => {
expect(versions[1]?.version).toBe("0.9.25");
});
it("retries network failures", async () => {
jest.useFakeTimers();
mockFetch
.mockRejectedValueOnce(new Error("request timed out"))
.mockResolvedValueOnce(
createMockResponse(true, 200, "OK", sampleManifestResponse),
);
const result = fetchManifest();
await jest.runAllTimersAsync();
await expect(result).resolves.toHaveLength(2);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("stops after the configured number of network failures", async () => {
jest.useFakeTimers();
mockFetch.mockRejectedValue(new Error("request timed out"));
const result = expect(fetchManifest()).rejects.toThrow(
"request timed out",
);
await jest.runAllTimersAsync();
await result;
expect(mockFetch).toHaveBeenCalledTimes(MANIFEST_FETCH_ATTEMPTS);
});
it("throws on a failed fetch", async () => {
mockFetch.mockResolvedValue(
createMockResponse(false, 500, "Internal Server Error", ""),
Generated Vendored
+19 -2
View File
@@ -99724,6 +99724,7 @@ function formatVariants(entries) {
// src/download/manifest.ts
var cachedManifestData = /* @__PURE__ */ new Map();
var MANIFEST_FETCH_ATTEMPTS = 3;
async function fetchManifest(manifestUrl = VERSIONS_MANIFEST_URL) {
const cachedManifest = cachedManifestData.get(manifestUrl);
if (cachedManifest?.complete === true) {
@@ -99802,8 +99803,24 @@ async function getArtifact(version3, arch3, platform2, manifestUrl = VERSIONS_MA
};
}
async function fetchManifestResponse(manifestUrl) {
info2(`Fetching manifest data from ${manifestUrl} ...`);
const response = await fetch(manifestUrl, {});
let response;
for (let attempt = 1; attempt <= MANIFEST_FETCH_ATTEMPTS; attempt++) {
info2(`Fetching manifest data from ${manifestUrl} ...`);
try {
response = await fetch(manifestUrl, {});
break;
} catch (error2) {
if (attempt >= MANIFEST_FETCH_ATTEMPTS) {
throw error2;
}
const delayMs = 1e3 * 2 ** (attempt - 1);
info2(`Manifest fetch failed; retrying in ${delayMs}ms ...`);
await new Promise((resolve3) => setTimeout(resolve3, delayMs));
}
}
if (response === void 0) {
throw new Error("Manifest fetch attempts exhausted.");
}
if (!response.ok) {
throw new Error(
`Failed to fetch manifest data: ${response.status} ${response.statusText}`
+19 -2
View File
@@ -52390,6 +52390,7 @@ function info2(msg) {
// src/download/manifest.ts
var cachedManifestData = /* @__PURE__ */ new Map();
var MANIFEST_FETCH_ATTEMPTS = 3;
async function fetchManifest(manifestUrl = VERSIONS_MANIFEST_URL) {
const cachedManifest = cachedManifestData.get(manifestUrl);
if (cachedManifest?.complete === true) {
@@ -52430,8 +52431,24 @@ async function getLatestVersion(manifestUrl = VERSIONS_MANIFEST_URL) {
return latestVersion;
}
async function fetchManifestResponse(manifestUrl) {
info2(`Fetching manifest data from ${manifestUrl} ...`);
const response = await fetch(manifestUrl, {});
let response;
for (let attempt = 1; attempt <= MANIFEST_FETCH_ATTEMPTS; attempt++) {
info2(`Fetching manifest data from ${manifestUrl} ...`);
try {
response = await fetch(manifestUrl, {});
break;
} catch (error2) {
if (attempt >= MANIFEST_FETCH_ATTEMPTS) {
throw error2;
}
const delayMs = 1e3 * 2 ** (attempt - 1);
info2(`Manifest fetch failed; retrying in ${delayMs}ms ...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
if (response === void 0) {
throw new Error("Manifest fetch attempts exhausted.");
}
if (!response.ok) {
throw new Error(
`Failed to fetch manifest data: ${response.status} ${response.statusText}`
+21 -2
View File
@@ -31,6 +31,7 @@ interface CachedManifest {
}
const cachedManifestData = new Map<string, CachedManifest>();
export const MANIFEST_FETCH_ATTEMPTS = 3;
export async function fetchManifest(
manifestUrl: string = VERSIONS_MANIFEST_URL,
@@ -166,8 +167,26 @@ export function clearManifestCache(manifestUrl?: string): void {
}
async function fetchManifestResponse(manifestUrl: string) {
log.info(`Fetching manifest data from ${manifestUrl} ...`);
const response = await fetch(manifestUrl, {});
let response: Awaited<ReturnType<typeof fetch>> | undefined;
for (let attempt = 1; attempt <= MANIFEST_FETCH_ATTEMPTS; attempt++) {
log.info(`Fetching manifest data from ${manifestUrl} ...`);
try {
response = await fetch(manifestUrl, {});
break;
} catch (error) {
if (attempt >= MANIFEST_FETCH_ATTEMPTS) {
throw error;
}
const delayMs = 1_000 * 2 ** (attempt - 1);
log.info(`Manifest fetch failed; retrying in ${delayMs}ms ...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
if (response === undefined) {
throw new Error("Manifest fetch attempts exhausted.");
}
if (!response.ok) {
throw new Error(
`Failed to fetch manifest data: ${response.status} ${response.statusText}`,