Skip to content

Commit a3ae605

Browse files
authored
fix: retry transient mcode-tools artifact download failures (#256)
1 parent a00c3e0 commit a3ae605

2 files changed

Lines changed: 128 additions & 3 deletions

File tree

‎scripts/lib/mcode-tools-artifact.mjs‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
22
import { mkdir, readFile, writeFile } from 'node:fs/promises';
33
import path from 'node:path';
44
import { Readable } from 'node:stream';
5+
import { setTimeout as sleep } from 'node:timers/promises';
56
import { Parser } from 'tar';
67

78
export const MCODE_TOOLS_ARTIFACT = Object.freeze({
@@ -51,15 +52,52 @@ export async function extractMcodeToolsArtifact(bytes) {
5152
return { cli, manifest: manifestBytes, notices };
5253
}
5354

55+
const transientNetworkCodes = new Set([
56+
'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN', 'ENETUNREACH',
57+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
58+
]);
59+
const transientHttpStatuses = new Set([408, 429, 500, 502, 503, 504]);
60+
61+
export async function downloadMcodeToolsArtifact(fetchImpl = fetch, { wait = sleep, warn = console.warn } = {}) {
62+
const attempts = 3;
63+
for (let attempt = 1; attempt <= attempts; attempt++) {
64+
let retryAfterMs = 0;
65+
try {
66+
// A new deadline covers both connection setup and the complete response body.
67+
const response = await fetchImpl(MCODE_TOOLS_ARTIFACT.url, { signal: AbortSignal.timeout(120_000) });
68+
if (!response.ok) {
69+
const retryAfter = response.headers.get('retry-after');
70+
if (retryAfter !== null) {
71+
const milliseconds = /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
72+
if (!Number.isNaN(milliseconds)) retryAfterMs = Math.min(30_000, Math.max(0, milliseconds));
73+
}
74+
await response.body?.cancel().catch(() => {});
75+
const error = new Error(`Cannot download public mcode-tools artifact: HTTP ${response.status}`);
76+
error.status = response.status;
77+
throw error;
78+
}
79+
return Buffer.from(await response.arrayBuffer());
80+
} catch (error) {
81+
const code = error.cause?.code ?? error.code;
82+
const retryable = transientHttpStatuses.has(error.status) ||
83+
transientNetworkCodes.has(code) || error.name === 'TimeoutError';
84+
if (!retryable) throw error;
85+
if (attempt === attempts)
86+
throw new Error(`Cannot download public mcode-tools artifact after ${attempts} attempts`, { cause: error });
87+
const delayMs = Math.max(1000 * 2 ** (attempt - 1), retryAfterMs);
88+
warn(`[mcode-tools] Download attempt ${attempt}/${attempts} failed (${code ?? error.message}); retrying in ${delayMs} ms.`);
89+
await wait(delayMs);
90+
}
91+
}
92+
}
93+
5494
export async function copyMcodeToolsArtifact(root, outdir, fetchImpl = fetch) {
5595
const cache = path.join(root, '.cache', 'artifacts', 'code-0.3.11.tgz');
5696
let bytes;
5797
try { bytes = await readFile(cache); }
5898
catch (error) {
5999
if (error.code !== 'ENOENT') throw error;
60-
const response = await fetchImpl(MCODE_TOOLS_ARTIFACT.url, { signal: AbortSignal.timeout(120_000) });
61-
if (!response.ok) throw new Error(`Cannot download public mcode-tools artifact: HTTP ${response.status}`);
62-
bytes = Buffer.from(await response.arrayBuffer());
100+
bytes = await downloadMcodeToolsArtifact(fetchImpl);
63101
}
64102
const artifact = await extractMcodeToolsArtifact(bytes);
65103
await mkdir(path.dirname(cache), { recursive: true });

‎test/source-sync.test.mjs‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,93 @@ import { releaseManifest } from '../scripts/package-cli-release.mjs';
1919
import { validateReleaseReports } from '../scripts/publish-cli-release.mjs';
2020
import { compareVersions, releaseCli } from '../scripts/release-cli.mjs';
2121
import { compareRuns, validateRun, validateRequest, validateToolOutput, median, selectScenarios } from '../scripts/perf/report.mjs';
22+
import { copyMcodeToolsArtifact, downloadMcodeToolsArtifact, MCODE_TOOLS_ARTIFACT } from '../scripts/lib/mcode-tools-artifact.mjs';
23+
24+
test('artifact download recovers from TLS reset and interrupted response bodies', async () => {
25+
const reset = new TypeError('fetch failed', { cause: Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) });
26+
const interrupted = new TypeError('terminated', { cause: Object.assign(new Error('socket closed'), { code: 'UND_ERR_SOCKET' }) });
27+
const signals = [], delays = [], warnings = [];
28+
const result = await downloadMcodeToolsArtifact(async (url, { signal }) => {
29+
assert.equal(url, MCODE_TOOLS_ARTIFACT.url);
30+
assert.ok(signal instanceof AbortSignal);
31+
signals.push(signal);
32+
if (signals.length === 1) throw reset;
33+
if (signals.length === 2) return new Response(new ReadableStream({
34+
start(controller) { controller.enqueue(new Uint8Array([1, 2])); },
35+
pull(controller) { controller.error(interrupted); },
36+
}));
37+
return new Response('complete archive');
38+
}, { wait: async ms => delays.push(ms), warn: message => warnings.push(message) });
39+
assert.equal(result.toString(), 'complete archive');
40+
assert.equal(new Set(signals).size, 3);
41+
assert.deepEqual(delays, [1000, 2000]);
42+
assert.equal(warnings.length, 2);
43+
assert.match(warnings[0], /1\/3.*ECONNRESET/);
44+
assert.match(warnings[1], /2\/3.*UND_ERR_SOCKET/);
45+
});
46+
47+
test('artifact download retries temporary HTTP failures and releases rejected bodies', async () => {
48+
for (const status of [408, 429, 500, 502, 503, 504]) {
49+
let calls = 0, cancelled = false;
50+
const delays = [];
51+
const result = await downloadMcodeToolsArtifact(async () => {
52+
if (++calls > 1) return new Response('ok');
53+
return new Response(new ReadableStream({ cancel() { cancelled = true; } }), { status });
54+
}, { wait: async ms => delays.push(ms), warn() {} });
55+
assert.equal(result.toString(), 'ok');
56+
assert.equal(calls, 2);
57+
assert.equal(cancelled, true);
58+
assert.deepEqual(delays, [1000]);
59+
}
60+
});
61+
62+
test('artifact download bounds Retry-After delays and rejects permanent failures immediately', async () => {
63+
for (const [header, expectedDelay] of [['5', 5000], ['999999', 30000], ['invalid', 1000], ['0', 1000]]) {
64+
let calls = 0;
65+
const delays = [];
66+
await downloadMcodeToolsArtifact(async () => ++calls === 1
67+
? new Response(null, { status: 429, headers: { 'Retry-After': header } }) : new Response('ok'),
68+
{ wait: async ms => delays.push(ms), warn() {} });
69+
assert.deepEqual(delays, [expectedDelay]);
70+
}
71+
const certificateError = new TypeError('fetch failed', { cause: Object.assign(new Error('certificate expired'), { code: 'CERT_HAS_EXPIRED' }) });
72+
for (const failure of [new Response(null, { status: 403 }), new Response(null, { status: 404 }), certificateError]) {
73+
let calls = 0;
74+
await assert.rejects(downloadMcodeToolsArtifact(async () => {
75+
calls++;
76+
if (failure instanceof Error) throw failure;
77+
return failure;
78+
}, { wait: async () => assert.fail('permanent failures must not wait'), warn: () => assert.fail('permanent failures must not retry') }));
79+
assert.equal(calls, 1);
80+
}
81+
});
82+
83+
test('artifact download stops after three timeouts and preserves the final cause', async () => {
84+
const timeout = new DOMException('The operation timed out', 'TimeoutError');
85+
let calls = 0;
86+
const delays = [];
87+
await assert.rejects(downloadMcodeToolsArtifact(async () => { calls++; throw timeout; },
88+
{ wait: async ms => delays.push(ms), warn() {} }), error => {
89+
assert.match(error.message, /after 3 attempts/);
90+
assert.equal(error.cause, timeout);
91+
return true;
92+
});
93+
assert.equal(calls, 3);
94+
assert.deepEqual(delays, [1000, 2000]);
95+
});
96+
97+
test('artifact download never retries or caches an archive that fails integrity', async t => {
98+
const root = mkdtempSync(path.join(tmpdir(), 'mcode-artifact-download-'));
99+
t.after(() => rmSync(root, { recursive: true, force: true }));
100+
let calls = 0;
101+
await assert.rejects(copyMcodeToolsArtifact(root, path.join(root, 'dist'), async () => {
102+
calls++;
103+
return new Response('corrupt archive');
104+
}), /integrity mismatch/);
105+
assert.equal(calls, 1);
106+
assert.equal(existsSync(path.join(root, '.cache', 'artifacts', 'code-0.3.11.tgz')), false);
107+
assert.equal(existsSync(path.join(root, 'dist')), false);
108+
});
22109

23110
test('performance defaults to the 100-round suite; long history requires explicit selection', () => {
24111
const config = JSON.parse(readFileSync(new URL('../scripts/perf/config.json', import.meta.url), 'utf8'));

0 commit comments

Comments
 (0)