Skip to content

Commit 4e812b8

Browse files
committed
refactor(github): retry GitHub requests through the fleet's shared pRetry
1 parent 07bbf95 commit 4e812b8

2 files changed

Lines changed: 212 additions & 66 deletions

File tree

src/utils/github-errors.mts

Lines changed: 124 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@
1212
* - Classifying a GitHub response as a blocking error (rate limit / abuse
1313
* detection / auth) with a clear, actionable message.
1414
* - A bounded-retry request wrapper that respects `Retry-After` /
15-
* `x-ratelimit-reset` for short reset windows and retries transient 5xx /
16-
* network failures with capped exponential backoff.
15+
* `x-ratelimit-reset` for short reset windows and hands transient 5xx /
16+
* network failures to the fleet's shared `pRetry` for backoff.
1717
*/
1818

19+
import process from 'node:process'
20+
import { setTimeout as sleep } from 'node:timers/promises'
21+
1922
import { debugFn } from '@socketsecurity/registry/lib/debug'
23+
import { envAsNumber } from '@socketsecurity/registry/lib/env'
2024
import { logger } from '@socketsecurity/registry/lib/logger'
25+
import { pRetry } from '@socketsecurity/registry/lib/promises'
2126

2227
import { apiFetch } from './api.mts'
2328
import { debugApiRequest, debugApiResponse } from './debug.mts'
@@ -35,12 +40,12 @@ import type { CResult } from '../types.mts'
3540
// constant for it in constants.mts, so define it locally.
3641
const HTTP_STATUS_TOO_MANY_REQUESTS = 429
3742

38-
// Retry at most this many times for transient (5xx / network) failures,
39-
// counting the initial attempt.
40-
const MAX_TRANSIENT_ATTEMPTS = 3
41-
42-
// Cap for exponential backoff between transient retries.
43-
const MAX_BACKOFF_MS = 10_000
43+
// Base delay before the first transient retry. Mirrors the default in
44+
// @socketsecurity/lib's releases/github-retry-config, including the env var
45+
// name, so both socket-cli lines back off against the GitHub API on the same
46+
// schedule. Read live rather than captured at import so a test or a CI job can
47+
// set it to 0 and skip the real wallclock wait.
48+
const DEFAULT_RETRY_BASE_DELAY_MS = 5000
4449

4550
// Only wait-and-retry a rate-limited response when the reset window is at
4651
// most this many seconds. The usual primary-limit reset is up to an hour
@@ -167,14 +172,43 @@ export function classifyGitHubResponse(
167172
return undefined
168173
}
169174

170-
function backoffMs(attempt: number): number {
171-
return Math.min(1000 * 2 ** (attempt - 1), MAX_BACKOFF_MS)
175+
/**
176+
* Retry policy for transient GitHub failures. Same values as the shared
177+
* GITHUB_RETRY_CONFIG in @socketsecurity/lib: two retries on top of the initial
178+
* attempt, delay doubling each time, capped at 10 seconds. Built per call
179+
* rather than at import so the env override is read live.
180+
*/
181+
function githubRetryOptions(): {
182+
backoffFactor: number
183+
baseDelayMs: number
184+
maxDelayMs: number
185+
retries: number
186+
} {
187+
return {
188+
backoffFactor: 2,
189+
baseDelayMs: envAsNumber(
190+
process.env['SOCKET_GITHUB_RETRY_BASE_DELAY_MS'],
191+
DEFAULT_RETRY_BASE_DELAY_MS,
192+
),
193+
maxDelayMs: 10_000,
194+
retries: 2,
195+
}
172196
}
173197

174-
function sleep(ms: number): Promise<void> {
175-
return new Promise(resolve => {
176-
setTimeout(resolve, ms)
177-
})
198+
/**
199+
* Thrown by one request attempt to tell `pRetry` what happened. `retryable`
200+
* false means another attempt cannot help, so the retry loop stops early
201+
* instead of burning its budget. The CResult is what the caller sees.
202+
*/
203+
class GitHubRequestFailure extends Error {
204+
result: CResult<never>
205+
retryable: boolean
206+
constructor(result: CResult<never>, retryable: boolean) {
207+
super(result.message)
208+
this.name = 'GitHubRequestFailure'
209+
this.result = result
210+
this.retryable = retryable
211+
}
178212
}
179213

180214
/**
@@ -192,7 +226,8 @@ function sleep(ms: number): Promise<void> {
192226
* short (<= CHEAP_RATE_LIMIT_WAIT_MAX_SECONDS); otherwise surface the error
193227
* immediately. Long primary-limit resets are not worth blocking on.
194228
* - Auth: never retried.
195-
* - 5xx / network: capped exponential backoff, MAX_TRANSIENT_ATTEMPTS total.
229+
* - 5xx / network: handed to the fleet's shared `pRetry` for exponential
230+
* backoff, on the policy in `githubRetryOptions`.
196231
*/
197232
export async function githubApiRequest(
198233
url: string,
@@ -205,31 +240,42 @@ export async function githubApiRequest(
205240
): Promise<CResult<{ response: Response; bodyText: string }>> {
206241
const method = init.method || 'GET'
207242
let rateLimitWaitUsed = false
208-
for (let attempt = 1; ; attempt += 1) {
243+
// pRetry rethrows whichever error it stored first. Track the newest one
244+
// ourselves so the caller always sees the failure that actually ended the
245+
// run, not an earlier one it had already recovered past.
246+
let lastFailure: GitHubRequestFailure | undefined
247+
248+
const fail = (
249+
result: CResult<never>,
250+
retryable: boolean,
251+
): GitHubRequestFailure => {
252+
const failure = new GitHubRequestFailure(result, retryable)
253+
lastFailure = failure
254+
return failure
255+
}
256+
257+
const attempt = async (): Promise<{
258+
response: Response
259+
bodyText: string
260+
}> => {
209261
debugApiRequest(method, url)
210262
let response: Response
211263
try {
212-
// eslint-disable-next-line no-await-in-loop
213264
response = await fetchImpl(url, init)
214265
debugApiResponse(method, url, response.status)
215266
} catch (e) {
216267
debugApiResponse(method, url, undefined, e)
217-
// Network-level failure (DNS, connection reset, timeout). Retry a few
218-
// times with bounded backoff before giving up.
219-
if (attempt < MAX_TRANSIENT_ATTEMPTS) {
220-
debugFn('notice', `retry: network error while ${context}`, attempt)
221-
// eslint-disable-next-line no-await-in-loop
222-
await sleep(backoffMs(attempt))
223-
continue
224-
}
225-
return {
226-
ok: false,
227-
message: 'Network error connecting to GitHub',
228-
cause: formatErrorWithDetail(`Network error while ${context}`, e),
229-
}
268+
// Network-level failure (DNS, connection reset, timeout).
269+
throw fail(
270+
{
271+
ok: false,
272+
message: 'Network error connecting to GitHub',
273+
cause: formatErrorWithDetail(`Network error while ${context}`, e),
274+
},
275+
true,
276+
)
230277
}
231278

232-
// eslint-disable-next-line no-await-in-loop
233279
const bodyText = await response.text()
234280

235281
const blocking = classifyGitHubResponse(
@@ -241,10 +287,10 @@ export async function githubApiRequest(
241287
if (blocking) {
242288
// Auth failures never succeed on retry.
243289
if (blocking.message === GITHUB_ERR_AUTH_FAILED) {
244-
return blocking
290+
throw fail(blocking, false)
245291
}
246-
// Rate limit / abuse: retry once, but only when the reset window is
247-
// short enough to be worth waiting on.
292+
// Rate limit / abuse: wait once, but only when the reset window is short
293+
// enough to be worth waiting on.
248294
const waitSeconds = getRateLimitWaitSeconds(response.headers)
249295
if (
250296
!rateLimitWaitUsed &&
@@ -255,35 +301,59 @@ export async function githubApiRequest(
255301
logger.info(
256302
`GitHub rate limit hit while ${context}; waiting ${waitSeconds}s before one retry...`,
257303
)
258-
// Add a second of slack so we retry just past the reset boundary.
259-
// eslint-disable-next-line no-await-in-loop
304+
// GitHub told us exactly when the quota comes back, so this wait is
305+
// honoring a server instruction rather than backing off. Backoff is
306+
// pRetry's job and its delay is capped well below a reset window.
307+
// A second of slack lands the retry just past the reset boundary.
260308
await sleep((waitSeconds + 1) * 1000)
261-
continue
309+
throw fail(blocking, true)
262310
}
263-
return blocking
311+
throw fail(blocking, false)
264312
}
265313

266-
// Transient server errors: retry with bounded backoff, then surface.
314+
// Transient server errors.
267315
if (response.status >= HTTP_STATUS_INTERNAL_SERVER_ERROR) {
268-
if (attempt < MAX_TRANSIENT_ATTEMPTS) {
316+
throw fail(
317+
{
318+
ok: false,
319+
message: 'GitHub server error',
320+
cause:
321+
`GitHub server error (${response.status}) while ${context}. ` +
322+
'GitHub may be experiencing issues; try again shortly.',
323+
},
324+
true,
325+
)
326+
}
327+
328+
return { response, bodyText }
329+
}
330+
331+
try {
332+
const data = await pRetry(attempt, {
333+
...githubRetryOptions(),
334+
onRetry(attemptNumber: number, e: unknown) {
335+
if (e instanceof GitHubRequestFailure && !e.retryable) {
336+
// Stop now; another attempt cannot change the answer.
337+
return false
338+
}
269339
debugFn(
270340
'notice',
271-
`retry: GitHub ${response.status} while ${context}`,
272-
attempt,
341+
`retry: ${e instanceof Error ? e.message : 'failure'} while ${context}`,
342+
attemptNumber,
273343
)
274-
// eslint-disable-next-line no-await-in-loop
275-
await sleep(backoffMs(attempt))
276-
continue
277-
}
278-
return {
344+
return undefined
345+
},
346+
onRetryCancelOnFalse: true,
347+
})
348+
return { ok: true, data }
349+
} catch {
350+
/* c8 ignore next - `lastFailure` is set on every throw out of `attempt`. */
351+
return (
352+
lastFailure?.result ?? {
279353
ok: false,
280-
message: 'GitHub server error',
281-
cause:
282-
`GitHub server error (${response.status}) while ${context}. ` +
283-
'GitHub may be experiencing issues; try again shortly.',
354+
message: 'GitHub request failed',
355+
cause: `GitHub request failed while ${context}.`,
284356
}
285-
}
286-
287-
return { ok: true, data: { response, bodyText } }
357+
)
288358
}
289359
}

0 commit comments

Comments
 (0)