feat(github): classify GitHub rate-limit, abuse-detection, and auth responses - #221
Open
John-David Dalton (jdalton) wants to merge 1 commit into
Open
feat(github): classify GitHub rate-limit, abuse-detection, and auth responses#221John-David Dalton (jdalton) wants to merge 1 commit into
John-David Dalton (jdalton) wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A GitHub API response that is throttled or rejected does not always look like an error. GitHub sends its primary rate limit as an HTTP
403carryingx-ratelimit-remaining: 0, which has no distinguishing status code at all. Code that reads the body without first checking the status reads that response as "this repository has nothing to return" and reports a run as successful when in fact nothing was fetched.This PR adds
github/error-classification, a small pure module that looks at a status code, the response headers, and the body text, and tells you whether you are looking at a rate limit, GitHub's abuse detection, or an auth failure. It takes plain data and returns plain data. There is no fetch call, no logger, and no result type in it, so any caller can run it against whatever HTTP client it already uses.The three conditions it recognizes are all blocking: they depend on your credential and on the clock, not on the resource you asked for. If you are looping over a hundred repositories and you hit one of them, every remaining repository will fail the same way. A caller should stop the loop rather than retry the request or quietly skip to the next repository. A plain permission denial is deliberately not in the set, because that one really is about the specific resource, so skipping it and carrying on is the right behaviour.
Why it belongs here rather than in a caller
socket-libalready owns the retry half of this problem.releases/github-retry-configholdsGITHUB_RETRY_CONFIGand documents itself as covering "the transient-failure / rate-limit surface", andpromises/retryholdspRetry. What was missing was the step before the retry decision: working out what a given response actually is. Without it, every caller that talks to the GitHub API has to rebuild the same status-and-header reading, and two copies of that logic will drift.The exported surface is four values and three types, and every one of them is exercised by a test
classifyGitHubErrorResponse(response){ body, headers, status }and returns a classification, orundefinedwhen the response is not one of the three blocking conditions.getGitHubRateLimitWaitSeconds(headers)Retry-Afteror fromx-ratelimit-reset.getGitHubResponseHeader(headers, name)GITHUB_BLOCKING_ERROR_KINDSGitHubErrorKind,GitHubErrorClassification,GitHubResponseHeadersTwo details worth calling out. First, the classifier accepts both a Fetch
Headersobject and the plain record that Node's HTTP layer produces, because callers in this codebase hold both shapes and neither should have to convert before asking a question. AHeadersalready matches names case-insensitively; a plain record does not, so the record path compares lowercased keys rather than trusting the caller to have normalized them.Second, the
Retry-Afterheader is parsed by the existinghttp-request/headershelper rather than by new code. RFC 7231 allows that header to be either a number of seconds or an absolute HTTP date, andparseRetryAfterHeaderalready handles both. Reusing it means there is one parser for that header in this repository instead of two.Order matters inside the classifier: abuse detection is checked before the primary rate limit because both arrive as HTTP 403
GitHub's secondary rate limit, which it calls abuse detection, arrives as a
403with a body that mentions it. The primary rate limit also arrives as a403. So does an ordinary permission denial. The status code alone cannot tell them apart, which is why the classifier reads the body and thex-ratelimit-remainingheader as well.The checks run most-specific first:
403withsecondary rate limitorabuse detectionin the body becomesabuse-detection.429, or403withx-ratelimit-remaining: 0, or403withrate limitin the body, becomesrate-limit.401becomesauth-failure, and is markedretryable: falsebecause the same token never recovers by waiting.undefined, so the caller keeps its own handling of404s, empty repositories, permission denials, and transient5xxresponses.Testing
The new suite is 27 cases and covers the module at 100% of lines, statements, branches, and functions, which clears the repository's 99/99/95 bar.
Every detector was mutation-checked: broken on purpose, confirmed a named test went red, then restored
A test you have not watched fail is not evidence. Each detector below was disabled in turn, the suite was run, and the named test that caught it is recorded. The source was restored and confirmed byte-identical afterwards.
x-ratelimit-remaining: 0detector removedreads a 403 with x-ratelimit-remaining: 0 as a rate limit,reads a 403 with x-ratelimit-remaining: 0 as a rate limit from a header recordreads a 403 secondary rate limit as abuse detection,reads a 403 abuse-detection body as abuse detection,prefers abuse detection over the rate limit when both would match401branch removedreads a 401 as an auth failure that waiting cannot clear429no longer counts as a rate limitreads a 429 as a rate limit even with no body,carries the reset window on a rate limitretryable: truereads a 401 as an auth failure that waiting cannot clear403treated as a rate limitreturns undefined for a 403 permission denial with quota remainingRetry-Afterignored in favour ofx-ratelimit-resetcarries the reset window on a rate limit,prefers retry-after in seconds,accepts an HTTP-date retry-afterfloors an already-elapsed reset at zeroThe first attempt at this is worth recording, because it caught a bad test. The
x-ratelimit-remaining: 0cases originally used a body that also said "rate limit", so deleting the header detector left the suite green — the body detector was silently covering for it. The fix was aSILENT_BODYfixture that says nothing about throttling, so a header test now proves the header detector fired. That is the exact shape of the bug this module exists to prevent, so it mattered that the test could actually see it.Ran
node scripts/fleet/test.mts test/unit/github/error-classification.test.mts— 27 passed, harness exit0.node scripts/fleet/lint.mts— passed, exit0, after fixing the import-sort, function-sort, and explicit-undefinedfindings it raised.node scripts/fleet/format.mts— exit0, and the formatter's rewrites are included in this commit.tsc --noEmit -p .config/fleet/tsconfig.check.json— exit0.node scripts/repo/make-api-md.mtsandnode scripts/fleet/gen/llms-txt.mts— regenerated rather than hand-edited, sodocs/api.mdandllms.txtpick the new subpath up automatically.Did not run
pnpm run check --allsuite to completion. A partial run showedcheck-dispatch-table-is-currentalready failing on a hook-bundle artifact that this branch does not touch; this branch is the default branch plus three files, so that finding is pre-existing rather than something introduced here.