-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy patherror-classification.ts
More file actions
231 lines (218 loc) · 8.16 KB
/
Copy patherror-classification.ts
File metadata and controls
231 lines (218 loc) · 8.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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<string, string | string[] | undefined>
/**
* 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<string, string | string[] | undefined>
for (const key of ObjectKeys(record)) {
if (StringPrototypeToLowerCase(key) !== name) {
continue
}
const value = record[key]
return ArrayIsArray(value) ? value[0] : value
}
return undefined
}