fix(installer): include URL, API message, and rate-limit info in HTTP errors

Non-2xx responses previously yielded bare `HTTP 403`. Now surface the
request URL, GitHub's JSON error message (or body snippet), X-RateLimit-Reset
when quota is exhausted, and Retry-After. Turns a mystery 403 into
'rate limit exhausted; resets at 2026-04-15T18:00:00Z' — the difference
between 'try GITHUB_TOKEN' and a wild goose chase.
This commit is contained in:
Alex Verkhovsky 2026-04-18 08:50:59 -07:00
parent 8c3301fc49
commit e2e032cd96
1 changed files with 43 additions and 8 deletions

View File

@ -1,6 +1,37 @@
const https = require('node:https'); const https = require('node:https');
const yaml = require('yaml'); const yaml = require('yaml');
/**
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
* JSON error message (or a truncated body snippet), rate-limit reset time,
* and Retry-After anything present that would help a user recover.
*/
function buildHttpError(url, res, body) {
const parts = [`HTTP ${res.statusCode} ${url}`];
if (body) {
try {
const parsed = JSON.parse(body);
if (parsed.message) parts.push(parsed.message);
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
} catch {
const snippet = body.slice(0, 200).trim();
if (snippet) parts.push(snippet);
}
}
const remaining = res.headers['x-ratelimit-remaining'];
const reset = res.headers['x-ratelimit-reset'];
if (remaining === '0' && reset) {
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
}
const retryAfter = res.headers['retry-after'];
if (retryAfter) parts.push(`retry after ${retryAfter}`);
return new Error(parts.join(' — '));
}
/** /**
* Shared HTTP client for fetching registry data from GitHub. * Shared HTTP client for fetching registry data from GitHub.
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager. * Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
@ -29,12 +60,14 @@ class RegistryClient {
} }
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject); return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
} }
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = ''; let data = '';
res.on('data', (chunk) => (data += chunk)); res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data)); res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
}) })
.on('error', reject) .on('error', reject)
.on('timeout', () => { .on('timeout', () => {
@ -133,12 +166,14 @@ class RegistryClient {
} }
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject); return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
} }
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = ''; let data = '';
res.on('data', (chunk) => (data += chunk)); res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data)); res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
}) })
.on('error', reject) .on('error', reject)
.on('timeout', () => { .on('timeout', () => {