diff --git a/docs/api.md b/docs/api.md index 3f01dacc1..69c53797c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -465,20 +465,21 @@ Each entry links to the source module and shows the first sentence of its `@file ## github/ -| Subpath | Description | -| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`@socketsecurity/lib-stable/github/commit`](../src/github/commit.ts) | Create a SIGNED commit on a branch via the GitHub git-objects API (blob -> tree -> commit -> ref PATCH). | -| [`@socketsecurity/lib-stable/github/constants`](../src/github/constants.ts) | GitHub API URL + cache-TTL constants. | -| [`@socketsecurity/lib-stable/github/errors`](../src/github/errors.ts) | Named errors thrown by `github/*` helpers. | -| [`@socketsecurity/lib-stable/github/ghsa`](../src/github/ghsa.ts) | GitHub Security Advisory (GHSA) lookups. | -| [`@socketsecurity/lib-stable/github/refs`](../src/github/refs.ts) | Resolve GitHub git refs (tag / branch / commit) to full commit SHAs. | -| [`@socketsecurity/lib-stable/github/refs-cache`](../src/github/refs-cache.ts) | TtlCache singleton for github/refs. | -| [`@socketsecurity/lib-stable/github/refs-graphql`](../src/github/refs-graphql.ts) | Resolve a GitHub git ref via GraphQL. | -| [`@socketsecurity/lib-stable/github/refs-rest`](../src/github/refs-rest.ts) | Resolve a GitHub git ref via REST tier-cascade. | -| [`@socketsecurity/lib-stable/github/request`](../src/github/request.ts) | Authenticated GitHub REST fetch. | -| [`@socketsecurity/lib-stable/github/token`](../src/github/token.ts) | GitHub token resolution. | -| [`@socketsecurity/lib-stable/github/types`](../src/github/types.ts) | Public type surface for `github/*` modules — pure interfaces. | -| [`@socketsecurity/lib-stable/github/workflow-runs`](../src/github/workflow-runs.ts) | Look up a GitHub Actions workflow run for a commit and classify its verdict — the "is CI green on this SHA?" primitive behind gated releases (bump/tag/publish only after the pre-release commit's CI passes). | +| Subpath | Description | +| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`@socketsecurity/lib-stable/github/commit`](../src/github/commit.ts) | Create a SIGNED commit on a branch via the GitHub git-objects API (blob -> tree -> commit -> ref PATCH). | +| [`@socketsecurity/lib-stable/github/constants`](../src/github/constants.ts) | GitHub API URL + cache-TTL constants. | +| [`@socketsecurity/lib-stable/github/error-classification`](../src/github/error-classification.ts) | Classify a GitHub API error response from its status, headers, and body. | +| [`@socketsecurity/lib-stable/github/errors`](../src/github/errors.ts) | Named errors thrown by `github/*` helpers. | +| [`@socketsecurity/lib-stable/github/ghsa`](../src/github/ghsa.ts) | GitHub Security Advisory (GHSA) lookups. | +| [`@socketsecurity/lib-stable/github/refs`](../src/github/refs.ts) | Resolve GitHub git refs (tag / branch / commit) to full commit SHAs. | +| [`@socketsecurity/lib-stable/github/refs-cache`](../src/github/refs-cache.ts) | TtlCache singleton for github/refs. | +| [`@socketsecurity/lib-stable/github/refs-graphql`](../src/github/refs-graphql.ts) | Resolve a GitHub git ref via GraphQL. | +| [`@socketsecurity/lib-stable/github/refs-rest`](../src/github/refs-rest.ts) | Resolve a GitHub git ref via REST tier-cascade. | +| [`@socketsecurity/lib-stable/github/request`](../src/github/request.ts) | Authenticated GitHub REST fetch. | +| [`@socketsecurity/lib-stable/github/token`](../src/github/token.ts) | GitHub token resolution. | +| [`@socketsecurity/lib-stable/github/types`](../src/github/types.ts) | Public type surface for `github/*` modules — pure interfaces. | +| [`@socketsecurity/lib-stable/github/workflow-runs`](../src/github/workflow-runs.ts) | Look up a GitHub Actions workflow run for a commit and classify its verdict — the "is CI green on this SHA?" primitive behind gated releases (bump/tag/publish only after the pre-release commit's CI passes). | ## globs/ diff --git a/llms.txt b/llms.txt index edf98f28d..c8f5ffd76 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # @socketsecurity/lib -> Core utilities and infrastructure for Socket.dev security tools. 586 subpath exports, grouped by namespace. +> Core utilities and infrastructure for Socket.dev security tools. 587 subpath exports, grouped by namespace. Import any namespace by its subpath, e.g. `import '@socketsecurity/lib/abort/signal'`. Each link below points at the TypeScript declarations shipped in the package, where the full signature for that subpath lives. @@ -406,6 +406,7 @@ Import any namespace by its subpath, e.g. `import '@socketsecurity/lib/abort/sig - [@socketsecurity/lib/github/commit](./dist/github/commit.d.ts): Create a SIGNED commit on a branch via the GitHub git-objects API (blob -> tree -> commit -> ref PATCH). - [@socketsecurity/lib/github/constants](./dist/github/constants.d.ts): GitHub API URL + cache-TTL constants. +- [@socketsecurity/lib/github/error-classification](./dist/github/error-classification.d.ts): Classify a GitHub API error response from its status, headers, and body. - [@socketsecurity/lib/github/errors](./dist/github/errors.d.ts): Named errors thrown by `github/*` helpers. - [@socketsecurity/lib/github/ghsa](./dist/github/ghsa.d.ts): GitHub Security Advisory (GHSA) lookups. - [@socketsecurity/lib/github/refs](./dist/github/refs.d.ts): Resolve GitHub git refs (tag / branch / commit) to full commit SHAs. diff --git a/package.json b/package.json index a072e426c..537596f28 100644 --- a/package.json +++ b/package.json @@ -1766,6 +1766,11 @@ "types": "./dist/github/constants.d.ts", "default": "./dist/github/constants.js" }, + "./github/error-classification": { + "source": "./src/github/error-classification.ts", + "types": "./dist/github/error-classification.d.ts", + "default": "./dist/github/error-classification.js" + }, "./github/errors": { "source": "./src/github/errors.ts", "types": "./dist/github/errors.d.ts", diff --git a/src/github/error-classification.ts b/src/github/error-classification.ts new file mode 100644 index 000000000..e3afae017 --- /dev/null +++ b/src/github/error-classification.ts @@ -0,0 +1,231 @@ +/** + * @file Classify a GitHub API error response from its status, headers, and + * body. Pure data in, pure data out — no fetch, no logging, no result type — + * so any caller can run it against whatever HTTP client it already uses. + * GitHub reports three conditions in ways that are easy to misread as + * ordinary failures, and misreading one turns a throttled run into a silent + * success. A rate limit arrives as HTTP 429, or as HTTP 403 carrying + * `x-ratelimit-remaining: 0` — that second form has no distinguishing status + * code, so code that reads the body without checking the status sees "this + * repo has nothing to return". Abuse detection, GitHub's secondary rate + * limit, arrives as HTTP 403 with a body saying so, sharing its status with + * both the rate-limit form and a plain permission denial. An auth failure + * arrives as HTTP 401, meaning the token is invalid, expired, or missing a + * scope, so no amount of waiting helps. + * All three are BLOCKING: they turn on the credential and the clock, not on + * the resource being requested, so every later request in a loop over repos + * fails the same way. A caller iterating resources should stop on the first + * one instead of retrying it or moving to the next. A plain permission denial + * is deliberately NOT one of them — that one IS about the resource, so + * skipping it and continuing is right. + * Retry policy for what comes back lives in `releases/github-retry-config`; + * this module only decides WHAT a response is. + */ + +import { parseRetryAfterHeader } from '../http-request/headers' + +import { ArrayIsArray } from '../primordials/array' +import { DateNow } from '../primordials/date' +import { MathFloor, MathMax } from '../primordials/math' +import { NumberIsFinite, NumberParseInt } from '../primordials/number' +import { ObjectFreeze, ObjectKeys } from '../primordials/object' +import { + StringPrototypeIncludes, + StringPrototypeToLowerCase, +} from '../primordials/string' + +/** + * Which blocking condition a GitHub error response represents. + * + * - `abuse-detection` — the secondary rate limit, tripped by bursty traffic. + * - `auth-failure` — the credential itself is rejected. + * - `rate-limit` — the primary hourly quota is spent. + */ +export type GitHubErrorKind = 'abuse-detection' | 'auth-failure' | 'rate-limit' + +/** + * Every kind {@link classifyGitHubErrorResponse} can return, in sorted order. + * + * Exported so a caller can derive its own blocking-condition table from this + * list rather than hard-coding one. A caller that does so picks up a future + * kind for free instead of silently treating it as an ordinary error. + */ +export const GITHUB_BLOCKING_ERROR_KINDS: readonly GitHubErrorKind[] = + ObjectFreeze(['abuse-detection', 'auth-failure', 'rate-limit']) + +/** + * What {@link classifyGitHubErrorResponse} decided about a response. Getting one + * back at all means the condition is blocking. + */ +export interface GitHubErrorClassification { + /** + * Which condition this is. + */ + kind: GitHubErrorKind + /** + * Whether waiting can clear the condition. `true` for the two rate limits, + * `false` for an auth failure, which the same token never recovers from. + */ + retryable: boolean + /** + * Seconds until the limit resets, read from `Retry-After` or + * `x-ratelimit-reset`. `undefined` when the response did not say, which is + * the common case for the primary hourly limit. + */ + waitSeconds: number | undefined +} + +/** + * Response headers in either shape a caller is likely to hold: a Fetch + * `Headers` object, or the plain record that Node's HTTP layer produces. + */ +export type GitHubResponseHeaders = + | { get(name: string): string | null } + | Record + +/** + * Classify a GitHub API response as one of the blocking conditions. + * + * Order matters: abuse detection is checked before the primary rate limit + * because both arrive as HTTP 403 and the abuse form is the more specific of + * the two. + * + * @example + * ;```ts + * const bodyText = await response.text() + * const blocked = classifyGitHubErrorResponse({ + * body: bodyText, + * headers: response.headers, + * status: response.status, + * }) + * if (blocked) { + * // Stop the loop; every later request fails the same way. + * } + * ``` + * + * @param response - The status, headers, and body text to classify. + * + * @returns The classification, or `undefined` when the response is not one of + * the blocking conditions. `undefined` covers healthy responses and ordinary + * errors alike, so the caller keeps its own handling of 404s, empty repos, + * and permission denials. + */ +export function classifyGitHubErrorResponse(response: { + body?: string | undefined + headers?: GitHubResponseHeaders | undefined + status: number +}): GitHubErrorClassification | undefined { + const { body, headers, status } = response + const lowerBody = body ? StringPrototypeToLowerCase(body) : '' + + if ( + status === 403 && + (StringPrototypeIncludes(lowerBody, 'secondary rate limit') || + StringPrototypeIncludes(lowerBody, 'abuse detection')) + ) { + return { + kind: 'abuse-detection', + retryable: true, + waitSeconds: getGitHubRateLimitWaitSeconds(headers), + } + } + + const remaining = getGitHubResponseHeader(headers, 'x-ratelimit-remaining') + if ( + status === 429 || + (status === 403 && + (remaining === '0' || StringPrototypeIncludes(lowerBody, 'rate limit'))) + ) { + return { + kind: 'rate-limit', + retryable: true, + waitSeconds: getGitHubRateLimitWaitSeconds(headers), + } + } + + if (status === 401) { + return { + kind: 'auth-failure', + retryable: false, + waitSeconds: undefined, + } + } + + return undefined +} + +/** + * Seconds to wait before a throttled GitHub request could succeed. + * + * Prefers `Retry-After`, which GitHub sends on secondary limits and which RFC + * 7231 allows to be either a delay in seconds or an absolute HTTP date. Falls + * back to `x-ratelimit-reset`, an absolute epoch-seconds timestamp, converted + * to a relative wait against the current clock and floored at zero so a reset + * already in the past reads as "no wait" instead of a negative number. + * + * @example + * ;```ts + * const seconds = getGitHubRateLimitWaitSeconds(response.headers) + * if (seconds !== undefined && seconds <= 30) { + * // Short enough to wait out. + * } + * ``` + * + * @param headers - The response headers. + * + * @returns Whole seconds to wait, or `undefined` when neither header is usable. + */ +export function getGitHubRateLimitWaitSeconds( + headers: GitHubResponseHeaders | undefined, +): number | undefined { + const retryAfterMs = parseRetryAfterHeader( + getGitHubResponseHeader(headers, 'retry-after'), + ) + if (retryAfterMs !== undefined) { + return MathFloor(retryAfterMs / 1000) + } + const reset = getGitHubResponseHeader(headers, 'x-ratelimit-reset') + if (reset) { + const resetEpochSeconds = NumberParseInt(reset, 10) + if (NumberIsFinite(resetEpochSeconds)) { + return MathMax(0, resetEpochSeconds - MathFloor(DateNow() / 1000)) + } + } + return undefined +} + +/** + * Read one header out of either header shape. + * + * A Fetch `Headers` already matches names case-insensitively. A plain record + * does not, so the record branch compares lowercased keys rather than trusting + * the caller to have normalized them. An array-valued header yields its first + * entry, matching how Node exposes repeated headers. + * + * @param headers - The response headers. + * @param name - The header name, lowercase. + * + * @returns The header value, or `undefined` when absent. + */ +export function getGitHubResponseHeader( + headers: GitHubResponseHeaders | undefined, + name: string, +): string | undefined { + if (!headers) { + return undefined + } + const getter = (headers as { get?: unknown | undefined }).get + if (typeof getter === 'function') { + const value = (headers as { get(name: string): string | null }).get(name) + return value === null ? undefined : value + } + const record = headers as Record + for (const key of ObjectKeys(record)) { + if (StringPrototypeToLowerCase(key) !== name) { + continue + } + const value = record[key] + return ArrayIsArray(value) ? value[0] : value + } + return undefined +} diff --git a/test/unit/github/error-classification.test.mts b/test/unit/github/error-classification.test.mts new file mode 100644 index 000000000..16c96e25e --- /dev/null +++ b/test/unit/github/error-classification.test.mts @@ -0,0 +1,224 @@ +/** + * @file Tests for github/error-classification — the pure classifier that turns + * a GitHub status + headers + body into a blocking-condition verdict. + * The bug this guards against: a GitHub 403 carrying + * `x-ratelimit-remaining: 0` has no distinguishing status code, so a caller + * that reads the body without checking the status reads a throttled response + * as "this repo has nothing to return" and reports a silent success. Every + * detector below is asserted from BOTH header shapes the classifier accepts, + * because a Fetch `Headers` and a Node header record disagree about key + * casing and about array values. + */ + +import { describe, expect, it } from 'vitest' + +import { + classifyGitHubErrorResponse, + getGitHubRateLimitWaitSeconds, + getGitHubResponseHeader, + GITHUB_BLOCKING_ERROR_KINDS, +} from '../../../src/github/error-classification.ts' + +const RATE_LIMIT_BODY = '{"message":"API rate limit exceeded for user 123."}' +const SECONDARY_LIMIT_BODY = + '{"message":"You have exceeded a secondary rate limit."}' +// A body that says nothing about throttling, so a test using it proves the +// HEADER detector fired rather than the body one. Without this the two +// detectors cover for each other and deleting either leaves the suite green. +const SILENT_BODY = '{"message":"Forbidden"}' + +describe('classifyGitHubErrorResponse', () => { + it('reads a 403 with x-ratelimit-remaining: 0 as a rate limit', () => { + const result = classifyGitHubErrorResponse({ + body: SILENT_BODY, + headers: new Headers({ 'x-ratelimit-remaining': '0' }), + status: 403, + }) + expect(result?.kind).toBe('rate-limit') + expect(result?.retryable).toBe(true) + }) + + it('reads a 403 with x-ratelimit-remaining: 0 as a rate limit from a header record', () => { + const result = classifyGitHubErrorResponse({ + body: SILENT_BODY, + headers: { 'X-RateLimit-Remaining': '0' }, + status: 403, + }) + expect(result?.kind).toBe('rate-limit') + }) + + it('reads a 429 as a rate limit even with no body', () => { + expect(classifyGitHubErrorResponse({ status: 429 })?.kind).toBe( + 'rate-limit', + ) + }) + + it('reads a 403 whose body mentions a rate limit as a rate limit', () => { + const result = classifyGitHubErrorResponse({ + body: RATE_LIMIT_BODY, + headers: new Headers(), + status: 403, + }) + expect(result?.kind).toBe('rate-limit') + }) + + it('reads a 403 secondary rate limit as abuse detection', () => { + const result = classifyGitHubErrorResponse({ + body: SECONDARY_LIMIT_BODY, + headers: new Headers(), + status: 403, + }) + expect(result?.kind).toBe('abuse-detection') + expect(result?.retryable).toBe(true) + }) + + it('reads a 403 abuse-detection body as abuse detection', () => { + const result = classifyGitHubErrorResponse({ + body: '{"message":"triggered abuse detection"}', + status: 403, + }) + expect(result?.kind).toBe('abuse-detection') + }) + + it('prefers abuse detection over the rate limit when both would match', () => { + const result = classifyGitHubErrorResponse({ + body: SECONDARY_LIMIT_BODY, + headers: new Headers({ 'x-ratelimit-remaining': '0' }), + status: 403, + }) + expect(result?.kind).toBe('abuse-detection') + }) + + it('reads a 401 as an auth failure that waiting cannot clear', () => { + const result = classifyGitHubErrorResponse({ status: 401 }) + expect(result?.kind).toBe('auth-failure') + expect(result?.retryable).toBe(false) + expect(result?.waitSeconds).toBeUndefined() + }) + + it('carries the reset window on a rate limit', () => { + const result = classifyGitHubErrorResponse({ + headers: new Headers({ 'retry-after': '42' }), + status: 429, + }) + expect(result?.waitSeconds).toBe(42) + }) + + it('returns undefined for a healthy 200', () => { + expect( + classifyGitHubErrorResponse({ body: '{}', status: 200 }), + ).toBeUndefined() + }) + + it('returns undefined for a 404 so the caller keeps its own handling', () => { + expect( + classifyGitHubErrorResponse({ body: '{}', status: 404 }), + ).toBeUndefined() + }) + + it('returns undefined for a 403 permission denial with quota remaining', () => { + const result = classifyGitHubErrorResponse({ + body: '{"message":"Must have admin rights to Repository."}', + headers: new Headers({ 'x-ratelimit-remaining': '4999' }), + status: 403, + }) + expect(result).toBeUndefined() + }) + + it('returns undefined for a 500 so the caller can treat it as transient', () => { + expect(classifyGitHubErrorResponse({ status: 503 })).toBeUndefined() + }) +}) + +describe('getGitHubRateLimitWaitSeconds', () => { + it('prefers retry-after in seconds', () => { + expect( + getGitHubRateLimitWaitSeconds(new Headers({ 'retry-after': '30' })), + ).toBe(30) + }) + + it('accepts an HTTP-date retry-after', () => { + const future = new Date(Date.now() + 20_000).toUTCString() + const seconds = getGitHubRateLimitWaitSeconds({ 'retry-after': future }) + expect(seconds).toBeGreaterThan(10) + expect(seconds).toBeLessThanOrEqual(20) + }) + + it('falls back to x-ratelimit-reset epoch seconds', () => { + const resetEpoch = Math.floor(Date.now() / 1000) + 25 + const seconds = getGitHubRateLimitWaitSeconds( + new Headers({ 'x-ratelimit-reset': String(resetEpoch) }), + ) + expect(seconds).toBeGreaterThan(20) + expect(seconds).toBeLessThanOrEqual(25) + }) + + it('floors an already-elapsed reset at zero', () => { + const past = Math.floor(Date.now() / 1000) - 600 + expect( + getGitHubRateLimitWaitSeconds({ 'x-ratelimit-reset': String(past) }), + ).toBe(0) + }) + + it('returns undefined when neither header is present', () => { + expect(getGitHubRateLimitWaitSeconds(new Headers())).toBeUndefined() + }) + + it('returns undefined for absent headers', () => { + expect(getGitHubRateLimitWaitSeconds(undefined)).toBeUndefined() + }) + + it('returns undefined for a non-numeric x-ratelimit-reset', () => { + expect( + getGitHubRateLimitWaitSeconds({ 'x-ratelimit-reset': 'soon' }), + ).toBeUndefined() + }) +}) + +describe('getGitHubResponseHeader', () => { + it('reads from a Fetch Headers object', () => { + expect( + getGitHubResponseHeader( + new Headers({ 'Retry-After': '5' }), + 'retry-after', + ), + ).toBe('5') + }) + + it('reads a differently-cased key out of a plain record', () => { + expect(getGitHubResponseHeader({ 'Retry-After': '5' }, 'retry-after')).toBe( + '5', + ) + }) + + it('takes the first entry of an array-valued header', () => { + expect( + getGitHubResponseHeader({ 'retry-after': ['7', '9'] }, 'retry-after'), + ).toBe('7') + }) + + it('returns undefined for a missing header', () => { + expect(getGitHubResponseHeader({}, 'retry-after')).toBeUndefined() + expect( + getGitHubResponseHeader(new Headers(), 'retry-after'), + ).toBeUndefined() + }) + + it('returns undefined for absent headers', () => { + expect(getGitHubResponseHeader(undefined, 'retry-after')).toBeUndefined() + }) +}) + +describe('GITHUB_BLOCKING_ERROR_KINDS', () => { + it('lists every kind the classifier can return', () => { + expect([...GITHUB_BLOCKING_ERROR_KINDS]).toEqual([ + 'abuse-detection', + 'auth-failure', + 'rate-limit', + ]) + }) + + it('is frozen so a caller cannot edit the shared list', () => { + expect(Object.isFrozen(GITHUB_BLOCKING_ERROR_KINDS)).toBe(true) + }) +})