Skip to content

Commit 2afdefe

Browse files
committed
feat(github): add AbortController timeout to fetchWithRetry with per-endpoint defaults
1 parent d28919b commit 2afdefe

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)