Skip to content

Commit ea582d1

Browse files
authored
fix(api): retry internal GitHub request timeouts (JhaSourav07#1947)
## Description Closes JhaSourav07#1942 ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) ## What changed `fetchWithRetry` already retries network failures, 5xx responses, and GitHub rate-limit responses, but request timeouts caused by the helper's own `AbortController` were thrown immediately on the first attempt. This PR routes internal timeout aborts through the existing retry/backoff path. Caller-provided abort signals still exit immediately, so user cancellation behavior is unchanged. I also added regression coverage for: - an internal timeout followed by a successful retry - internal timeouts exhausting all retry attempts - the existing caller-abort behavior staying non-retryable This is a GSSoC 2026 API reliability bug fix contribution. Please add the relevant GSSoC labels if they are missing. ## Visual Preview N/A — this is API retry behavior and test coverage only. ## Local checks ```bash npm run test -- lib/github.test.ts npm run format:check npm run lint npm run typecheck npm run test ``` `npm run lint` reports 4 existing warnings outside the touched files; there are no lint errors. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally. - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors. - [x] My commit follows the Conventional Commits format. - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred 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 quality standard.
2 parents ab0b44d + b12d831 commit ea582d1

2 files changed

Lines changed: 66 additions & 3 deletions

File tree

lib/github.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,57 @@ describe('fetchWithRetry', () => {
118118
expect(fetch).toHaveBeenCalledOnce();
119119
});
120120

121+
it('retries when the internal request timeout aborts fetch', async () => {
122+
vi.mocked(fetch)
123+
.mockImplementationOnce(
124+
(_url: RequestInfo | URL, options?: RequestInit) =>
125+
new Promise<Response>((_resolve, reject) => {
126+
options?.signal?.addEventListener(
127+
'abort',
128+
() => reject(new DOMException('Timed out', 'AbortError')),
129+
{ once: true }
130+
);
131+
})
132+
)
133+
.mockResolvedValueOnce(new Response('ok', { status: 200 }));
134+
135+
const promise = fetchWithRetry('https://api.github.com/test', {}, 0, 100);
136+
137+
await vi.advanceTimersByTimeAsync(100);
138+
await vi.advanceTimersByTimeAsync(500);
139+
140+
const res = await promise;
141+
expect(res.status).toBe(200);
142+
expect(fetch).toHaveBeenCalledTimes(2);
143+
});
144+
145+
it('throws a timeout error after internal timeout retries are exhausted', async () => {
146+
vi.mocked(fetch).mockImplementation(
147+
(_url: RequestInfo | URL, options?: RequestInit) =>
148+
new Promise<Response>((_resolve, reject) => {
149+
options?.signal?.addEventListener(
150+
'abort',
151+
() => reject(new DOMException('Timed out', 'AbortError')),
152+
{ once: true }
153+
);
154+
})
155+
);
156+
157+
const promise = fetchWithRetry('https://api.github.com/test', {}, 0, 100);
158+
const expectation = expect(promise).rejects.toThrow('GitHub API request timed out after 0.1s');
159+
160+
await vi.advanceTimersByTimeAsync(100);
161+
await vi.advanceTimersByTimeAsync(500);
162+
await vi.advanceTimersByTimeAsync(100);
163+
await vi.advanceTimersByTimeAsync(1000);
164+
await vi.advanceTimersByTimeAsync(100);
165+
await vi.advanceTimersByTimeAsync(2000);
166+
await vi.advanceTimersByTimeAsync(100);
167+
168+
await expectation;
169+
expect(fetch).toHaveBeenCalledTimes(4);
170+
});
171+
121172
it('retries on 429 with numeric retry-after', async () => {
122173
vi.mocked(fetch)
123174
.mockResolvedValueOnce(new Response(null, { status: 429, headers: { 'retry-after': '2' } }))

lib/github.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ const MAX_RETRY_DELAY_MS = 5000;
2828
const GRAPHQL_TIMEOUT_MS = 8000; // 8s for GraphQL endpoint
2929
const REST_TIMEOUT_MS = 5000; // 5s for REST endpoints
3030

31+
function isAbortError(error: unknown): boolean {
32+
return (
33+
typeof error === 'object' &&
34+
error !== null &&
35+
'name' in error &&
36+
(error as { name?: unknown }).name === 'AbortError'
37+
);
38+
}
39+
3140
export async function fetchWithRetry(
3241
url: string | URL,
3342
options: RequestInit,
@@ -63,10 +72,13 @@ export async function fetchWithRetry(
6372

6473
if (didThrow) {
6574
if (options.signal?.aborted) throw fetchError;
66-
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
67-
throw new Error(`GitHub API request timed out after ${resolvedTimeout / 1000}s`);
75+
const isTimeoutAbort = isAbortError(fetchError);
76+
if (attempt >= MAX_RETRIES) {
77+
if (isTimeoutAbort) {
78+
throw new Error(`GitHub API request timed out after ${resolvedTimeout / 1000}s`);
79+
}
80+
throw fetchError;
6881
}
69-
if (attempt >= MAX_RETRIES) throw fetchError;
7082
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
7183
await new Promise((resolve) => setTimeout(resolve, delay));
7284
return fetchWithRetry(url, options, attempt + 1, timeoutMs);

0 commit comments

Comments
 (0)