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+
1922import { debugFn } from '@socketsecurity/registry/lib/debug'
23+ import { envAsNumber } from '@socketsecurity/registry/lib/env'
2024import { logger } from '@socketsecurity/registry/lib/logger'
25+ import { pRetry } from '@socketsecurity/registry/lib/promises'
2126
2227import { apiFetch } from './api.mts'
2328import { 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.
3641const 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 */
197232export 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