Retry installer fetches after interrupted responses

This commit is contained in:
SAPHID committed 2026-10-28 14:30:23 +00:00
1 parent 3d06824f4d
commit 082af24115
4 files changed
+65 -30

No files matched your search

+1 -26
View File
@@ -7,10 +7,9 @@
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
const path = require("path");
const { execFileSync } = require("child_process");
const { fetchWithRetry } = require("./fetch-retry");
const { get, fetchWithRetry } = require("./fetch-retry");
const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
@@ -47,30 +46,6 @@ const PRUNE = [
.flatMap((d) => [`lib/python3.12/${d}`, `Lib/${d}`]),
];
function get(url, redirects = 5) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1));
}
if (res.statusCode !== 200) {
res.resume();
const error = new Error(`${url}: HTTP ${res.statusCode}`);
error.statusCode = res.statusCode;
return reject(error);
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
async function download(url, sha256, file) {
const data = await fetchWithRetry(get, url);
const sum = crypto.createHash("sha256").update(data).digest("hex");
+29 -1
View File
@@ -1,3 +1,31 @@
const https = require("https");
function get(url, redirects = 5, transport = https) {
return new Promise((resolve, reject) => {
transport.get(url, { timeout: 60000 }, (res) => {
res.on("error", reject);
res.on("aborted", () => reject(new Error(`${url}: response aborted`)));
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (!redirects) return reject(new Error(`${url}: too many redirects`));
let next;
try { next = new URL(res.headers.location, url).href; }
catch { return reject(new Error(`${url}: bad redirect ${res.headers.location}`)); }
return resolve(get(next, redirects - 1, transport));
}
if (res.statusCode !== 200) {
res.resume();
const error = new Error(`${url}: HTTP ${res.statusCode}`);
error.statusCode = res.statusCode;
return reject(error);
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
}).on("timeout", function () { this.destroy(new Error(`${url}: timed out`)); }).on("error", reject);
});
}
// Release assets are pinned by SHA-256 at the call site. Retry only transport
// failures and transient HTTP responses; the caller still rejects bad bytes.
async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
@@ -12,4 +40,4 @@ async function fetchWithRetry(fetcher, url, pause = (ms) => new Promise((resolve
}
}
module.exports = { fetchWithRetry };
module.exports = { get, fetchWithRetry };
+32 -1
View File
@@ -1,6 +1,7 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const test = require('node:test');
const { fetchWithRetry } = require('../build/fetch-retry');
const { get, fetchWithRetry } = require('../build/fetch-retry');
test('installer fetch recovers a dropped connection without hiding final failure', async () => {
let calls = 0;
@@ -43,3 +44,33 @@ test('installer fetch retries a transient HTTP response', async () => {
assert.equal(bytes.toString(), 'pinned bytes');
assert.equal(calls, 2);
});
test('installer fetch retries a response aborted after headers and partial bytes', async () => {
let calls = 0;
const transport = {
get(_url, _options, callback) {
const request = new EventEmitter();
queueMicrotask(() => {
const response = new EventEmitter();
response.statusCode = 200;
response.headers = {};
callback(response);
if (++calls === 1) {
response.emit('data', Buffer.from('partial'));
response.emit('aborted');
} else {
response.emit('data', Buffer.from('complete'));
response.emit('end');
}
});
return request;
},
};
const bytes = await fetchWithRetry(
(url) => get(url, 5, transport),
'https://example.invalid/package',
async () => {},
);
assert.equal(bytes.toString(), 'complete');
assert.equal(calls, 2);
});
@@ -13,8 +13,9 @@ other than 408/429 fail immediately. The existing pinned SHA-256 comparison
still happens after download and before writing package bytes, so altered
content fails without retry or installation.
Local Node 24 checks passed: three retry cases (recovery, exhausted retries,
permanent HTTP error and HTTP 503), all six app test files, script syntax and
Local Node 24 checks passed: retry recovery, exhausted retries, permanent HTTP
error, HTTP 503 and a response aborted after partial bytes; all six app test
files, script syntax and
`git diff --check`. Hosted exact-head CI and installer preview remain required
before merge/release. No installer was published, and this test makes no
claim about a physical Mac or Windows install.