diff --git a/src/github.ts b/src/github.ts index 90a8afa..0ec7013 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,8 +1,9 @@ import fs from 'node:fs'; +import type { IncomingHttpHeaders } from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getJSON } from './http.ts'; +import { getJSON, HttpError } from './http.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_OWNER = 'SocketDev'; @@ -64,8 +65,32 @@ export interface LatestRelease { assets: Assets; } +// Unauthenticated GitHub API calls are limited to 60/hour per IP, which shared +// CI runners and NAT'd networks exhaust quickly. Use a token when one is set. +function githubToken(): string | undefined { + return process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || undefined; +} + +function apiHeaders(token: string | undefined): Record { + return { + Accept: 'application/vnd.github+json', + ...(token && { Authorization: `Bearer ${token}` }), + }; +} + export async function fetchLatest(): Promise { - const json = await getJSON(LATEST_API, { userAgent: USER_AGENT }); + const token = githubToken(); + // biome-ignore lint/suspicious/noExplicitAny: reasonable type for the GitHub API response + let json: any; + try { + json = await getJSON(LATEST_API, { userAgent: USER_AGENT, headers: apiHeaders(token) }); + } catch (e) { + // An expired or invalid token shouldn't break a public, anonymous fetch. + if (!(token && e instanceof HttpError && e.statusCode === 401)) { + throw e; + } + json = await getJSON(LATEST_API, { userAgent: USER_AGENT, headers: apiHeaders(undefined) }); + } return { tag: json.tag_name, name: json.name || json.tag_name, @@ -73,6 +98,46 @@ export async function fetchLatest(): Promise { }; } +// When the limit lifts. Secondary limits send `retry-after`, which takes +// precedence: `x-ratelimit-reset` is on every response and is the primary +// hourly window, so it's only meaningful once that quota is used up. +function rateLimitWait(headers: IncomingHttpHeaders): string { + const retryAfter = Number(headers['retry-after']); + if (Number.isFinite(retryAfter) && retryAfter >= 0) { + return ` (retry after ${retryAfter}s)`; + } + const reset = Number(headers['x-ratelimit-reset']); + if (headers['x-ratelimit-remaining'] === '0' && Number.isFinite(reset) && reset > 0) { + return ` (resets at ${new Date(reset * 1000).toISOString()})`; + } + return ''; +} + +// Turn a fetchLatest() failure into a message that says what went wrong and, +// where we can tell, what to do about it. +export function describeFetchError(e: unknown): string { + if (e instanceof HttpError) { + const remaining = e.headers['x-ratelimit-remaining']; + const retryAfter = e.headers['retry-after']; + const rateLimited = + e.statusCode === 429 || + (e.statusCode === 403 && (remaining === '0' || retryAfter !== undefined)); + if (rateLimited) { + // Hint at a token only if the failing request didn't already send one + // (it may have been rejected and retried anonymously). + const hint = e.authenticated + ? 'The configured GITHUB_TOKEN/GH_TOKEN has also hit its limit.' + : 'Set GITHUB_TOKEN (or GH_TOKEN) to raise the limit.'; + return `${e.message}: GitHub API rate limit exceeded${rateLimitWait(e.headers)}. ${hint}`; + } + return e.message; + } + if (e instanceof Error) { + return e.message; + } + return String(e); +} + export function findAssetUrl( assets: Array<{ name: string; browser_download_url: string; digest: string }>, name: string, diff --git a/src/http.ts b/src/http.ts index 261b527..1693f94 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage } from 'node:http'; +import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'; import https from 'node:https'; import { URL } from 'node:url'; @@ -8,6 +8,38 @@ export interface HttpGetOptions { userAgent?: string; } +export class HttpError extends Error { + readonly url: string; + readonly statusCode: number; + readonly headers: IncomingHttpHeaders; + // Whether the request that failed carried an Authorization header. + readonly authenticated: boolean; + + constructor( + url: string, + statusCode: number, + headers: IncomingHttpHeaders, + authenticated: boolean, + ) { + super(`GET ${url} failed with ${statusCode}`); + this.name = 'HttpError'; + this.url = url; + this.statusCode = statusCode; + this.headers = headers; + this.authenticated = authenticated; + } +} + +function isAuthorizationHeader(name: string): boolean { + return name.toLowerCase() === 'authorization'; +} + +// Credentials must never follow a redirect to a different host (e.g. from +// api.github.com to the release-asset CDN). +function withoutAuthorization(headers: Record): Record { + return Object.fromEntries(Object.entries(headers).filter(([k]) => !isAuthorizationHeader(k))); +} + export async function httpsGet( url: string, options: HttpGetOptions = {}, @@ -31,11 +63,20 @@ export async function httpsGet( } if (statusCode >= 300 && statusCode < 400 && h.location && maxRedirects > 0) { res.resume(); - return resolve(httpsGet(h.location, { ...options, maxRedirects: maxRedirects - 1 })); + const next = new URL(h.location, u); + const nextHeaders = next.host === u.host ? headers : withoutAuthorization(headers); + return resolve( + httpsGet(next.toString(), { + ...options, + headers: nextHeaders, + maxRedirects: maxRedirects - 1, + }), + ); } if (statusCode < 200 || statusCode >= 300) { res.resume(); - return reject(new Error(`GET ${url} failed with ${statusCode}`)); + const authenticated = Object.keys(headers).some(isAuthorizationHeader); + return reject(new HttpError(url, statusCode, h, authenticated)); } resolve(res); }) diff --git a/src/sfw.ts b/src/sfw.ts index b85342f..0c7ef05 100644 --- a/src/sfw.ts +++ b/src/sfw.ts @@ -5,7 +5,13 @@ import { fileURLToPath } from 'node:url'; import yoctoSpinner from 'yocto-spinner'; -import { expectedAssetName, fetchLatest, findAssetUrl, type LatestRelease } from './github.ts'; +import { + describeFetchError, + expectedAssetName, + fetchLatest, + findAssetUrl, + type LatestRelease, +} from './github.ts'; import { eagerAcquireLockAndDownload } from './resumable.ts'; import { swallowError } from './util.ts'; @@ -166,8 +172,11 @@ async function ensureLatestBinary(): Promise { setNextCheckTimeSync(); try { releaseInfo = await fetchLatest(); - } catch (_e) { - throw new Error('Unable to fetch latest release and no valid cached release found.'); + } catch (e) { + throw new Error( + `Unable to fetch latest release and no valid cached release found: ${describeFetchError(e)}`, + { cause: e }, + ); } return await downloadAndVerifyReleaseSync(releaseInfo, assetName); }