@@ -13,35 +13,51 @@ const MAX_RETRIES = 3;
1313const BASE_DELAY_MS = 500 ;
1414const CONTRIBUTION_MILESTONES = [ 1 , 10 , 100 , 250 , 500 , 1000 ] ;
1515const STREAK_MILESTONES = [ 3 , 7 , 30 , 100 ] ;
16+ const GRAPHQL_TIMEOUT_MS = 8000 ; // 8s for GraphQL endpoint
17+ const REST_TIMEOUT_MS = 5000 ; // 5s for REST endpoints
1618
17- /**
18- * Wraps fetch with exponential backoff retry logic.
19- * Retries ONLY on network errors, 429 Too Many Requests, and 5xx server errors.
20- * Returns immediately for all other statuses (2xx success, 3xx redirects, 4xx client errors).
21- */
2219export async function fetchWithRetry (
2320 url : string ,
2421 options : RequestInit ,
25- attempt = 0
22+ attempt = 0 ,
23+ timeoutMs ?: number
2624) : Promise < Response > {
25+ // Determine default timeout based on endpoint type if not explicitly provided.
26+ // GraphQL calls carry a larger payload and need a slightly longer window.
27+ const resolvedTimeout =
28+ timeoutMs ?? ( url . includes ( 'graphql' ) ? GRAPHQL_TIMEOUT_MS : REST_TIMEOUT_MS ) ;
29+
30+ // Each retry attempt gets a fresh AbortController so the timeout window
31+ // resets per attempt — not cumulative across the entire retry chain.
32+ const controller = new AbortController ( ) ;
33+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , resolvedTimeout ) ;
34+
2735 let res : Response | null = null ;
2836 try {
29- res = await fetch ( url , options ) ;
37+ res = await fetch ( url , { ... options , signal : controller . signal } ) ;
3038 } catch ( err ) {
39+ clearTimeout ( timeoutId ) ;
40+ // AbortError means the timeout fired — throw a clear typed message.
41+ if ( err instanceof Error && err . name === 'AbortError' ) {
42+ const seconds = resolvedTimeout / 1000 ;
43+ throw new Error ( `GitHub API request timed out after ${ seconds } s` ) ;
44+ }
3145 // Network error — retry if attempts remain, otherwise rethrow
3246 if ( attempt >= MAX_RETRIES ) throw err ;
3347 const delay = BASE_DELAY_MS * Math . pow ( 2 , attempt ) ;
3448 await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
35- return fetchWithRetry ( url , options , attempt + 1 ) ;
49+ return fetchWithRetry ( url , options , attempt + 1 , timeoutMs ) ;
3650 }
3751
52+ clearTimeout ( timeoutId ) ;
53+
3854 // Only retry on 429 or 5xx — all other statuses are returned immediately
3955 const shouldRetry = res . status === 429 || res . status >= 500 ;
4056 if ( ! shouldRetry || attempt >= MAX_RETRIES ) return res ;
4157
4258 const delay = BASE_DELAY_MS * Math . pow ( 2 , attempt ) ;
4359 await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
44- return fetchWithRetry ( url , options , attempt + 1 ) ;
60+ return fetchWithRetry ( url , options , attempt + 1 , timeoutMs ) ;
4561}
4662
4763const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql' ;
0 commit comments