Skip to content

Commit ce6368f

Browse files
authored
fix(github): retry on GraphQL RATE_LIMITED errors returned with HTTP 200 (closes JhaSourav07#1269) (JhaSourav07#1710)
## Description Closes JhaSourav07#1269 GitHub's GraphQL API returns rate limit errors **with HTTP 200 OK**, embedding the error inside `data.errors` as `{ type: 'RATE_LIMITED' }`. Because `fetchWithRetry` only inspects HTTP status codes, it considers the 200 a success and returns immediately - `fetchGitHubContributions` then throws an unrecoverable error instead of backing off and retrying. **Root cause:** `fetchWithRetry` has no visibility into the response body, so body-level GraphQL errors bypass all retry logic. **Fix:** Adds a thin `fetchGraphQLWithRetry` wrapper around `fetchWithRetry`. After a 200 OK response, it clones and peeks the response body for `RATE_LIMITED` errors. If found, it applies the same `BASE_DELAY_MS * 2^attempt` exponential backoff used for HTTP 429, up to the same `MAX_RETRIES` cap and 5 000 ms delay guard. `fetchWithRetry` itself is unchanged - the fix is fully isolated. ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview N/A - server-side API fix with no visual output changes. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts).
2 parents 432b9c6 + f60d6d3 commit ce6368f

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
@@ -317,6 +317,52 @@ describe('fetchGitHubContributions', () => {
317317
);
318318
});
319319

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

lib/github.ts

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

2525
const MAX_RETRIES = 3;
2626
const BASE_DELAY_MS = 500;
27+
const MAX_RETRY_DELAY_MS = 5000;
2728
const GRAPHQL_TIMEOUT_MS = 8000; // 8s for GraphQL endpoint
2829
const REST_TIMEOUT_MS = 5000; // 5s for REST endpoints
2930

@@ -99,7 +100,7 @@ export async function fetchWithRetry(
99100

100101
// If the delay is too long (e.g., > 5 seconds), it's a hard limit.
101102
// Return immediately to avoid serverless function timeouts.
102-
if (delay > 5000) {
103+
if (delay > MAX_RETRY_DELAY_MS) {
103104
return res;
104105
}
105106

@@ -116,6 +117,38 @@ export async function fetchWithRetry(
116117
return fetchWithRetry(url, options, attempt + 1, timeoutMs);
117118
}
118119

120+
// Wraps fetchWithRetry to also retry on GraphQL-level RATE_LIMITED errors
121+
// that GitHub returns with HTTP 200 OK instead of 429.
122+
async function fetchGraphQLWithRetry(
123+
url: string | URL,
124+
options: RequestInit,
125+
attempt = 0,
126+
timeoutMs?: number
127+
): Promise<Response> {
128+
const res = await fetchWithRetry(url, options, attempt, timeoutMs);
129+
if (!res.ok || attempt >= MAX_RETRIES) return res;
130+
131+
const body: unknown = await res
132+
.clone()
133+
.json()
134+
.catch(() => null);
135+
const isBodyRateLimited =
136+
Array.isArray((body as { errors?: unknown })?.errors) &&
137+
(body as { errors: unknown[] }).errors.some(
138+
(e) =>
139+
(e as { type?: string })?.type === 'RATE_LIMITED' ||
140+
(e as { message?: string })?.message?.toLowerCase().includes('rate limit')
141+
);
142+
143+
if (!isBodyRateLimited) return res;
144+
145+
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
146+
if (delay > MAX_RETRY_DELAY_MS) return res;
147+
148+
await new Promise((resolve) => setTimeout(resolve, delay));
149+
return fetchGraphQLWithRetry(url, options, attempt + 1, timeoutMs);
150+
}
151+
119152
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
120153
const GITHUB_REST_URL = 'https://api.github.com';
121154
type GitHubRateLimitInfo = {
@@ -384,7 +417,7 @@ export async function fetchGitHubContributions(
384417
}
385418
`;
386419

387-
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
420+
const res = await fetchGraphQLWithRetry(GITHUB_GRAPHQL_URL, {
388421
method: 'POST',
389422
headers: getHeaders(),
390423
body: JSON.stringify({

0 commit comments

Comments
 (0)