diff --git a/tools/installer/modules/registry-client.js b/tools/installer/modules/registry-client.js index bf891de07..31a38f8d3 100644 --- a/tools/installer/modules/registry-client.js +++ b/tools/installer/modules/registry-client.js @@ -1,6 +1,37 @@ const https = require('node:https'); 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. * Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager. @@ -29,12 +60,14 @@ class RegistryClient { } 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 = ''; 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('timeout', () => { @@ -133,12 +166,14 @@ class RegistryClient { } 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 = ''; 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('timeout', () => {