Skip to content

Commit f60d6d3

Browse files
committed
fix(github): retry on GraphQL RATE_LIMITED errors returned with HTTP 200
GitHub's GraphQL API embeds rate limit errors in the response body with HTTP 200 OK, bypassing fetchWithRetry's status-code-based retry logic. Adds fetchGraphQLWithRetry to detect body-level RATE_LIMITED errors and apply the same exponential backoff used for HTTP 429 responses. Extracts MAX_RETRY_DELAY_MS constant shared by both retry paths. Closes JhaSourav07#1269
1 parent 6ede310 commit f60d6d3

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

lib/github.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,52 @@ describe('fetchGitHubContributions', () => {
316316
);
317317
});
318318

319+
// GitHub GraphQL returns HTTP 200 for rate limit errors — the error lives in the body.
320+
// fetchGraphQLWithRetry must detect it and back off, not crash immediately.
321+
describe('body-level RATE_LIMITED retry (HTTP 200)', () => {
322+
beforeEach(() => {
323+
vi.useFakeTimers();
324+
});
325+
326+
afterEach(() => {
327+
vi.useRealTimers();
328+
});
329+
330+
it('retries with backoff when GitHub returns RATE_LIMITED inside a 200 OK body', async () => {
331+
vi.mocked(fetch)
332+
.mockResolvedValueOnce(
333+
mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] })
334+
)
335+
.mockResolvedValueOnce(
336+
mockResponse({
337+
data: { user: { contributionsCollection: { contributionCalendar: mockCalendar } } },
338+
})
339+
);
340+
341+
const promise = fetchGitHubContributions('octocat');
342+
await vi.advanceTimersByTimeAsync(500);
343+
const result = await promise;
344+
345+
expect(fetch).toHaveBeenCalledTimes(2);
346+
expect(result.totalContributions).toBe(mockCalendar.totalContributions);
347+
});
348+
349+
it('throws after exhausting all retries on repeated body-level RATE_LIMITED errors', async () => {
350+
vi.mocked(fetch).mockResolvedValue(
351+
mockResponse({ errors: [{ type: 'RATE_LIMITED', message: 'API rate limit exceeded' }] })
352+
);
353+
354+
const promise = fetchGitHubContributions('octocat');
355+
// Register the rejection handler before advancing timers so the rejection
356+
// is never "unhandled" during the timer callbacks.
357+
const assertion = expect(promise).rejects.toThrow('API Rate Limit Exceeded');
358+
// 500ms + 1000ms + 2000ms covers all 3 retry delays before attempt >= MAX_RETRIES
359+
await vi.advanceTimersByTimeAsync(3500);
360+
await assertion;
361+
expect(fetch).toHaveBeenCalledTimes(4);
362+
});
363+
});
364+
319365
it('throws a descriptive "user not found" error when the username does not exist on GitHub', async () => {
320366
vi.mocked(fetch).mockResolvedValue(mockResponse({ data: { user: null } }));
321367

lib/github.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface GitHubRepo {
1313

1414
const MAX_RETRIES = 3;
1515
const BASE_DELAY_MS = 500;
16+
const MAX_RETRY_DELAY_MS = 5000;
1617
const GRAPHQL_TIMEOUT_MS = 8000; // 8s for GraphQL endpoint
1718
const REST_TIMEOUT_MS = 5000; // 5s for REST endpoints
1819

@@ -88,7 +89,7 @@ export async function fetchWithRetry(
8889

8990
// If the delay is too long (e.g., > 5 seconds), it's a hard limit.
9091
// Return immediately to avoid serverless function timeouts.
91-
if (delay > 5000) {
92+
if (delay > MAX_RETRY_DELAY_MS) {
9293
return res;
9394
}
9495

@@ -105,6 +106,38 @@ export async function fetchWithRetry(
105106
return fetchWithRetry(url, options, attempt + 1, timeoutMs);
106107
}
107108

109+
// Wraps fetchWithRetry to also retry on GraphQL-level RATE_LIMITED errors
110+
// that GitHub returns with HTTP 200 OK instead of 429.
111+
async function fetchGraphQLWithRetry(
112+
url: string | URL,
113+
options: RequestInit,
114+
attempt = 0,
115+
timeoutMs?: number
116+
): Promise<Response> {
117+
const res = await fetchWithRetry(url, options, attempt, timeoutMs);
118+
if (!res.ok || attempt >= MAX_RETRIES) return res;
119+
120+
const body: unknown = await res
121+
.clone()
122+
.json()
123+
.catch(() => null);
124+
const isBodyRateLimited =
125+
Array.isArray((body as { errors?: unknown })?.errors) &&
126+
(body as { errors: unknown[] }).errors.some(
127+
(e) =>
128+
(e as { type?: string })?.type === 'RATE_LIMITED' ||
129+
(e as { message?: string })?.message?.toLowerCase().includes('rate limit')
130+
);
131+
132+
if (!isBodyRateLimited) return res;
133+
134+
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
135+
if (delay > MAX_RETRY_DELAY_MS) return res;
136+
137+
await new Promise((resolve) => setTimeout(resolve, delay));
138+
return fetchGraphQLWithRetry(url, options, attempt + 1, timeoutMs);
139+
}
140+
108141
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
109142
const GITHUB_REST_URL = 'https://api.github.com';
110143
type GitHubRateLimitInfo = {
@@ -298,7 +331,7 @@ export async function fetchGitHubContributions(
298331
}
299332
`;
300333

301-
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
334+
const res = await fetchGraphQLWithRetry(GITHUB_GRAPHQL_URL, {
302335
method: 'POST',
303336
headers: getHeaders(),
304337
body: JSON.stringify({

0 commit comments

Comments
 (0)