Skip to content

Commit 9cf71e1

Browse files
authored
feat(github): add AbortController timeout to fetchWithRetry with per-endpoint defaults (JhaSourav07#423)
## Description Fixes JhaSourav07#273 - Added two constants `GRAPHQL_TIMEOUT_MS` and `REST_TIMEOUT_MS` for the default timeouts - Added optional `timeoutMs` parameter to `fetchWithRetry` for `per-call override` - Added `AbortController` with `setTimeout` — fires after the resolved timeout and aborts the fetch - Passed `signal: controller.signal` into the fetch options - `clearTimeout` called in both success and error paths to avoid memory leaks - `AbortError` is caught specifically and throws a clean typed message "GitHub API request timed out after Xs" - Recursive retry calls pass `timeoutMs` through so each attempt gets its own fresh timeout window - All existing retry logic for network errors and 5xx remains completely intact - No other files need changes — `route.ts` error SVG already catches all Error instances and renders them cleanly. ## Pillar - 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview - With `GRAPHQL_TIMEOUT_MS` and `REST_TIMEOUT_MS` set to a very small value like `1`: <img width="508" height="313" alt="Screenshot 2026-05-24 at 8 50 32 PM" src="https://github.com/user-attachments/assets/34f91c6c-2b64-4855-abc4-87c7f6b29c2b" /> <img width="516" height="210" alt="Screenshot 2026-05-24 at 8 50 52 PM" src="https://github.com/user-attachments/assets/4789b216-c796-48de-9faf-6927535ef5f5" /> - With `GRAPHQL_TIMEOUT_MS` and `REST_TIMEOUT_MS` set to appropriate values like `8000` & `5000` respectively: <img width="668" height="546" alt="Screenshot 2026-05-24 at 8 51 35 PM" src="https://github.com/user-attachments/assets/44d7383d-e2bd-4b19-b4cf-71fbefe7a3bb" /> ## Checklist before requesting a review: - I have read the `CONTRIBUTING.md` file. - I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - I have run `npm run format` and `npm run lint` locally and resolved all errors. - My commits follow the Conventional Commits format. - I have starred the repo. - I have made sure that i have only one commit to merge in this PR. - The SVG output matches the CommitPulse "premium quality" aesthetic standard.
2 parents 4035de3 + 2afdefe commit 9cf71e1

2 files changed

Lines changed: 25 additions & 27 deletions

File tree

lib/github.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,35 +13,51 @@ const MAX_RETRIES = 3;
1313
const BASE_DELAY_MS = 500;
1414
const CONTRIBUTION_MILESTONES = [1, 10, 100, 250, 500, 1000];
1515
const 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-
*/
2219
export 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

4763
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';

package-lock.json

Lines changed: 0 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)