@@ -61,7 +61,53 @@ export async function fetchWithRetry(
6161
6262const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql' ;
6363const GITHUB_REST_URL = 'https://api.github.com' ;
64- const MISSING_GITHUB_TOKEN_MESSAGE = 'GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN.' ;
64+ type GitHubRateLimitInfo = {
65+ limit : number | null ;
66+ remaining : number | null ;
67+ reset : number | null ;
68+ resetAt : string | null ;
69+ } ;
70+
71+ function parseRateLimitHeader ( value : string | null ) : number | null {
72+ if ( ! value ) return null ;
73+
74+ const parsed = parseInt ( value , 10 ) ;
75+ return Number . isFinite ( parsed ) ? parsed : null ;
76+ }
77+
78+ function getGitHubRateLimitInfo ( res : Response ) : GitHubRateLimitInfo {
79+ const limit = parseRateLimitHeader ( res . headers . get ( 'x-ratelimit-limit' ) ) ;
80+ const remaining = parseRateLimitHeader ( res . headers . get ( 'x-ratelimit-remaining' ) ) ;
81+ const reset = parseRateLimitHeader ( res . headers . get ( 'x-ratelimit-reset' ) ) ;
82+
83+ return {
84+ limit,
85+ remaining,
86+ reset,
87+ resetAt : reset ? new Date ( reset * 1000 ) . toISOString ( ) : null ,
88+ } ;
89+ }
90+
91+ function createRateLimitError ( res : Response ) : Error {
92+ const rateLimit = getGitHubRateLimitInfo ( res ) ;
93+ const resetMessage = rateLimit . resetAt ? ` Please try again after ${ rateLimit . resetAt } .` : '' ;
94+
95+ return new Error (
96+ `GitHub API rate limit exceeded.${ resetMessage } Configure GITHUB_TOKEN to increase the request limit.`
97+ ) ;
98+ }
99+
100+ function throwIfRateLimited ( res : Response ) : void {
101+ const rateLimit = getGitHubRateLimitInfo ( res ) ;
102+
103+ if ( res . status === 403 && rateLimit . remaining === 0 ) {
104+ throw createRateLimitError ( res ) ;
105+ }
106+
107+ if ( res . status === 429 ) {
108+ throw createRateLimitError ( res ) ;
109+ }
110+ }
65111
66112type GitHubContributionResponse = {
67113 data ?: {
@@ -127,7 +173,11 @@ export function clearGitHubApiCacheForTests(): void {
127173
128174function getGitHubToken ( ) : string {
129175 const token = process . env . GITHUB_PAT || process . env . GITHUB_TOKEN ;
130- if ( ! token || token . trim ( ) === '' ) throw new Error ( MISSING_GITHUB_TOKEN_MESSAGE ) ;
176+ const MISSING_GITHUB_TOKEN_MESSAGE = 'GitHub token is missing. Set GITHUB_PAT or GITHUB_TOKEN.' ;
177+ if ( ! token || token . trim ( ) === '' ) {
178+ throw new Error ( MISSING_GITHUB_TOKEN_MESSAGE ) ;
179+ }
180+
131181 return token ;
132182}
133183
@@ -190,6 +240,7 @@ export async function fetchGitHubContributions(
190240 } ) ;
191241
192242 if ( ! res . ok ) {
243+ throwIfRateLimited ( res ) ;
193244 if ( res . status === 401 ) throw new Error ( 'GitHub PAT is invalid or missing' ) ;
194245 throw new Error ( `GitHub GraphQL API returned status ${ res . status } ` ) ;
195246 }
@@ -252,6 +303,7 @@ export async function fetchUserProfile(
252303 } ) ;
253304
254305 if ( ! res . ok ) {
306+ throwIfRateLimited ( res ) ;
255307 if ( res . status === 404 ) throw new Error ( 'User not found' ) ;
256308 throw new Error ( `GitHub REST API error: ${ res . status } ` ) ;
257309 }
@@ -270,9 +322,8 @@ export async function fetchUserRepos(
270322 const cached = reposCache . get ( key ) ;
271323 if ( cached ) return cached ;
272324 }
273- const allRepos : GitHubRepo [ ] = [ ] ;
274325
275- const res = await fetchWithRetry (
326+ const firstPageRes = await fetchWithRetry (
276327 `${ GITHUB_REST_URL } /users/${ username } /repos?per_page=100&page=1&sort=pushed` ,
277328 {
278329 headers : getHeaders ( ) ,
@@ -281,25 +332,45 @@ export async function fetchUserRepos(
281332 }
282333 ) ;
283334
284- if ( ! res . ok ) throw new Error ( `GitHub REST API error: ${ res . status } ` ) ;
285- const firstPageRepos = ( await res . json ( ) ) as GitHubRepo [ ] ;
286- allRepos . push ( ...firstPageRepos ) ;
335+ if ( ! firstPageRes . ok ) {
336+ throwIfRateLimited ( firstPageRes ) ;
337+ throw new Error ( `GitHub REST API error: ${ firstPageRes . status } ` ) ;
338+ }
339+
340+ const firstPageRepos = ( await firstPageRes . json ( ) ) as GitHubRepo [ ] ;
341+ const allRepos : GitHubRepo [ ] = [ ...firstPageRepos ] ;
342+
343+ const MAX_PAGES = 3 ;
287344
288345 if ( firstPageRepos . length === 100 ) {
289- const fetchPromises = [ 2 , 3 ] . map ( ( page ) =>
290- fetchWithRetry (
291- `${ GITHUB_REST_URL } /users/${ username } /repos?per_page=100&page=${ page } &sort=pushed` ,
292- {
293- headers : getHeaders ( ) ,
294- cache : 'no-store' ,
295- signal : options . signal ,
296- }
346+ const remainingPages = Array . from ( { length : MAX_PAGES - 1 } , ( _ , i ) => i + 2 ) ;
347+
348+ const responses = await Promise . all (
349+ remainingPages . map ( ( page ) =>
350+ fetchWithRetry (
351+ `${ GITHUB_REST_URL } /users/${ username } /repos?per_page=100&page=${ page } &sort=pushed` ,
352+ {
353+ headers : getHeaders ( ) ,
354+ cache : 'no-store' ,
355+ signal : options . signal ,
356+ }
357+ )
297358 )
298359 ) ;
299360
300- const responses = await Promise . all ( fetchPromises ) ;
301- for ( const response of responses ) {
302- if ( response . ok ) allRepos . push ( ...( ( await response . json ( ) ) as GitHubRepo [ ] ) ) ;
361+ const pagesRepos = await Promise . all (
362+ responses . map ( async ( response ) => {
363+ if ( ! response . ok ) {
364+ throwIfRateLimited ( response ) ;
365+ throw new Error ( `GitHub REST API error: ${ response . status } ` ) ;
366+ }
367+
368+ return ( await response . json ( ) ) as GitHubRepo [ ] ;
369+ } )
370+ ) ;
371+
372+ for ( const repos of pagesRepos ) {
373+ allRepos . push ( ...repos ) ;
303374 }
304375 }
305376
0 commit comments