Skip to content

Commit c59cdba

Browse files
fix(worker): handle 5xx errors in http retry logic (#855)
* fix * changelog
1 parent fb358d8 commit c59cdba

File tree

6 files changed

+266
-9
lines changed

6 files changed

+266
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
### Fixed
1414
- Fixed issue where the branch filter in the repos detail page would not return any results. [#851](https://github.com/sourcebot-dev/sourcebot/pull/851)
15+
- Fixed issue where 5xx http errors would not be retried. [#855](https://github.com/sourcebot-dev/sourcebot/pull/855)
1516

1617
## [4.10.25] - 2026-02-04
1718

packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"gitea-js": "^1.22.0",
4747
"glob": "^11.1.0",
4848
"groupmq": "^1.0.0",
49+
"http-status-codes": "^2.3.0",
4950
"ioredis": "^5.4.2",
5051
"lowdb": "^7.0.1",
5152
"micromatch": "^4.0.8",

packages/backend/src/github.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Octokit } from "@octokit/rest";
2+
import { RequestError } from "@octokit/request-error";
23
import * as Sentry from "@sentry/node";
34
import { getTokenFromConfig } from "@sourcebot/shared";
45
import { createLogger } from "@sourcebot/shared";
@@ -49,6 +50,20 @@ export const supportsOAuthScopeIntrospection = (tokenType: GitHubTokenType): boo
4950
return SCOPE_INTROSPECTABLE_TOKEN_TYPES.includes(tokenType);
5051
};
5152

53+
/**
54+
* Type guard to check if an error is an Octokit RequestError.
55+
*/
56+
export const isOctokitRequestError = (error: unknown): error is RequestError => {
57+
return (
58+
error !== null &&
59+
typeof error === 'object' &&
60+
'status' in error &&
61+
typeof error.status === 'number' &&
62+
'name' in error &&
63+
error.name === 'HttpError'
64+
);
65+
};
66+
5267
// Limit concurrent GitHub requests to avoid hitting rate limits and overwhelming installations.
5368
const MAX_CONCURRENT_GITHUB_QUERIES = 5;
5469
const githubQueryLimit = pLimit(MAX_CONCURRENT_GITHUB_QUERIES);

packages/backend/src/utils.test.ts

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
import { expect, test } from 'vitest';
2-
import { arraysEqualShallow } from './utils';
1+
import { expect, test, describe, vi, beforeEach, afterEach } from 'vitest';
2+
import { arraysEqualShallow, fetchWithRetry } from './utils';
33
import { isRemotePath } from '@sourcebot/shared';
4+
import { Logger } from 'winston';
5+
import { RequestError } from '@octokit/request-error';
6+
7+
vi.mock('@sentry/node', () => ({
8+
captureException: vi.fn(),
9+
}));
10+
11+
const createMockLogger = (): Logger => ({
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
info: vi.fn(),
15+
debug: vi.fn(),
16+
} as unknown as Logger);
417

518
test('should return true for identical arrays', () => {
619
expect(arraysEqualShallow([1, 2, 3], [1, 2, 3])).toBe(true);
@@ -69,3 +82,214 @@ test('isRemotePath should return false for non HTTP paths', () => {
6982
expect(isRemotePath('')).toBe(false);
7083
expect(isRemotePath(' ')).toBe(false);
7184
});
85+
86+
describe('fetchWithRetry', () => {
87+
beforeEach(() => {
88+
vi.useFakeTimers();
89+
});
90+
91+
afterEach(() => {
92+
vi.useRealTimers();
93+
});
94+
95+
test('returns result on successful fetch', async () => {
96+
const logger = createMockLogger();
97+
const fetchFn = vi.fn().mockResolvedValue('success');
98+
99+
const result = await fetchWithRetry(fetchFn, 'test', logger);
100+
101+
expect(result).toBe('success');
102+
expect(fetchFn).toHaveBeenCalledTimes(1);
103+
});
104+
105+
test('throws immediately for non-retryable errors (e.g., 404)', async () => {
106+
const logger = createMockLogger();
107+
const error = { status: 404, message: 'Not Found' };
108+
const fetchFn = vi.fn().mockRejectedValue(error);
109+
110+
await expect(fetchWithRetry(fetchFn, 'test', logger)).rejects.toEqual(error);
111+
expect(fetchFn).toHaveBeenCalledTimes(1);
112+
});
113+
114+
test('throws immediately for non-retryable errors (e.g., 401)', async () => {
115+
const logger = createMockLogger();
116+
const error = { status: 401, message: 'Unauthorized' };
117+
const fetchFn = vi.fn().mockRejectedValue(error);
118+
119+
await expect(fetchWithRetry(fetchFn, 'test', logger)).rejects.toEqual(error);
120+
expect(fetchFn).toHaveBeenCalledTimes(1);
121+
});
122+
123+
test('retries on 429 (Too Many Requests) and succeeds', async () => {
124+
const logger = createMockLogger();
125+
const error = { status: 429, message: 'Too Many Requests' };
126+
const fetchFn = vi.fn()
127+
.mockRejectedValueOnce(error)
128+
.mockResolvedValueOnce('success');
129+
130+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger);
131+
132+
// Advance timer to trigger retry
133+
await vi.advanceTimersByTimeAsync(3000);
134+
135+
const result = await resultPromise;
136+
expect(result).toBe('success');
137+
expect(fetchFn).toHaveBeenCalledTimes(2);
138+
expect(logger.warn).toHaveBeenCalled();
139+
});
140+
141+
test('retries on 403 (Forbidden) and succeeds', async () => {
142+
const logger = createMockLogger();
143+
const error = { status: 403, message: 'Forbidden' };
144+
const fetchFn = vi.fn()
145+
.mockRejectedValueOnce(error)
146+
.mockResolvedValueOnce('success');
147+
148+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger);
149+
150+
await vi.advanceTimersByTimeAsync(3000);
151+
152+
const result = await resultPromise;
153+
expect(result).toBe('success');
154+
expect(fetchFn).toHaveBeenCalledTimes(2);
155+
});
156+
157+
test('retries on 503 (Service Unavailable) and succeeds', async () => {
158+
const logger = createMockLogger();
159+
const error = { status: 503, message: 'Service Unavailable' };
160+
const fetchFn = vi.fn()
161+
.mockRejectedValueOnce(error)
162+
.mockResolvedValueOnce('success');
163+
164+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger);
165+
166+
await vi.advanceTimersByTimeAsync(3000);
167+
168+
const result = await resultPromise;
169+
expect(result).toBe('success');
170+
expect(fetchFn).toHaveBeenCalledTimes(2);
171+
});
172+
173+
test('retries on 500 (Internal Server Error) and succeeds', async () => {
174+
const logger = createMockLogger();
175+
const error = { status: 500, message: 'Internal Server Error' };
176+
const fetchFn = vi.fn()
177+
.mockRejectedValueOnce(error)
178+
.mockResolvedValueOnce('success');
179+
180+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger);
181+
182+
await vi.advanceTimersByTimeAsync(3000);
183+
184+
const result = await resultPromise;
185+
expect(result).toBe('success');
186+
expect(fetchFn).toHaveBeenCalledTimes(2);
187+
});
188+
189+
test('throws after max attempts exceeded', async () => {
190+
const logger = createMockLogger();
191+
const error = { status: 429, message: 'Too Many Requests' };
192+
const fetchFn = vi.fn().mockRejectedValue(error);
193+
194+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger, 3);
195+
// Prevent unhandled rejection warning while we advance timers
196+
resultPromise.catch(() => {});
197+
198+
// Advance through all retry attempts
199+
await vi.advanceTimersByTimeAsync(3000); // 1st retry
200+
await vi.advanceTimersByTimeAsync(6000); // 2nd retry
201+
202+
await expect(resultPromise).rejects.toEqual(error);
203+
expect(fetchFn).toHaveBeenCalledTimes(3);
204+
});
205+
206+
test('uses exponential backoff for wait times', async () => {
207+
const logger = createMockLogger();
208+
const error = { status: 429, message: 'Too Many Requests' };
209+
const fetchFn = vi.fn()
210+
.mockRejectedValueOnce(error)
211+
.mockRejectedValueOnce(error)
212+
.mockResolvedValueOnce('success');
213+
214+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger, 4);
215+
216+
// First retry: 3000 * 2^0 = 3000ms
217+
await vi.advanceTimersByTimeAsync(3000);
218+
expect(fetchFn).toHaveBeenCalledTimes(2);
219+
220+
// Second retry: 3000 * 2^1 = 6000ms
221+
await vi.advanceTimersByTimeAsync(6000);
222+
expect(fetchFn).toHaveBeenCalledTimes(3);
223+
224+
const result = await resultPromise;
225+
expect(result).toBe('success');
226+
});
227+
228+
test('respects x-ratelimit-reset header for Octokit errors', async () => {
229+
const logger = createMockLogger();
230+
const now = Date.now();
231+
const resetTime = Math.floor((now + 5000) / 1000); // 5 seconds from now
232+
233+
const error = new RequestError('Rate limit exceeded', 429, {
234+
response: {
235+
headers: {
236+
'x-ratelimit-reset': String(resetTime),
237+
},
238+
status: 429,
239+
url: 'https://api.github.com/test',
240+
data: {},
241+
},
242+
request: {
243+
method: 'GET',
244+
url: 'https://api.github.com/test',
245+
headers: {},
246+
},
247+
});
248+
249+
const fetchFn = vi.fn()
250+
.mockRejectedValueOnce(error)
251+
.mockResolvedValueOnce('success');
252+
253+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger);
254+
255+
// Should wait approximately 5000ms based on the reset header
256+
await vi.advanceTimersByTimeAsync(5000);
257+
258+
const result = await resultPromise;
259+
expect(result).toBe('success');
260+
expect(fetchFn).toHaveBeenCalledTimes(2);
261+
});
262+
263+
test('respects custom maxAttempts parameter', async () => {
264+
const logger = createMockLogger();
265+
const error = { status: 503, message: 'Service Unavailable' };
266+
const fetchFn = vi.fn().mockRejectedValue(error);
267+
268+
const resultPromise = fetchWithRetry(fetchFn, 'test', logger, 2);
269+
// Prevent unhandled rejection warning while we advance timers
270+
resultPromise.catch(() => {});
271+
272+
await vi.advanceTimersByTimeAsync(3000);
273+
274+
await expect(resultPromise).rejects.toEqual(error);
275+
expect(fetchFn).toHaveBeenCalledTimes(2);
276+
});
277+
278+
test('logs warning on each retry attempt', async () => {
279+
const logger = createMockLogger();
280+
const error = { status: 429, message: 'Too Many Requests' };
281+
const fetchFn = vi.fn()
282+
.mockRejectedValueOnce(error)
283+
.mockResolvedValueOnce('success');
284+
285+
const resultPromise = fetchWithRetry(fetchFn, 'test-identifier', logger);
286+
287+
await vi.advanceTimersByTimeAsync(3000);
288+
await resultPromise;
289+
290+
expect(logger.warn).toHaveBeenCalledTimes(1);
291+
expect(logger.warn).toHaveBeenCalledWith(
292+
expect.stringContaining('test-identifier')
293+
);
294+
});
295+
});

packages/backend/src/utils.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { Logger } from "winston";
22
import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
33
import path from 'path';
4-
import { Repo } from "@sourcebot/db";
54
import { getTokenFromConfig } from "@sourcebot/shared";
65
import * as Sentry from "@sentry/node";
76
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
87
import { GithubAppManager } from "./ee/githubAppManager.js";
98
import { hasEntitlement } from "@sourcebot/shared";
10-
import { REPOS_CACHE_DIR } from "./constants.js";
9+
import { StatusCodes } from "http-status-codes";
10+
import { isOctokitRequestError } from "./github.js";
1111

1212
export const measure = async <T>(cb: () => Promise<T>) => {
1313
const start = Date.now();
@@ -72,10 +72,25 @@ export const fetchWithRetry = async <T>(
7272
Sentry.captureException(e);
7373

7474
attempts++;
75-
if ((e.status === 403 || e.status === 429 || e.status === 443) && attempts < maxAttempts) {
76-
const computedWaitTime = 3000 * Math.pow(2, attempts - 1);
77-
const resetTime = e.response?.headers?.['x-ratelimit-reset'] ? parseInt(e.response.headers['x-ratelimit-reset']) * 1000 : Date.now() + computedWaitTime;
78-
const waitTime = resetTime - Date.now();
75+
if (
76+
(
77+
(e.status >= 500 && e.status < 600) ||
78+
e.status === StatusCodes.FORBIDDEN ||
79+
e.status === StatusCodes.TOO_MANY_REQUESTS
80+
) && attempts < maxAttempts
81+
) {
82+
const resetDateMs = (() => {
83+
// First, try to see if we have a reset date specified in the response headers
84+
if (isOctokitRequestError(e) && e.response?.headers['x-ratelimit-reset']) {
85+
return parseInt(e.response.headers['x-ratelimit-reset']) * 1000;
86+
}
87+
88+
// Default to a exponential backoff approach
89+
const defaultWaitTime = 3000 * Math.pow(2, attempts - 1);
90+
return Date.now() + defaultWaitTime;
91+
})();
92+
93+
const waitTime = Math.max(0, resetDateMs - Date.now());
7994
logger.warn(`Rate limit exceeded for ${identifier}. Waiting ${waitTime}ms before retry ${attempts}/${maxAttempts}...`);
8095

8196
await new Promise(resolve => setTimeout(resolve, waitTime));
@@ -258,7 +273,7 @@ export const setIntervalAsync = (target: () => Promise<void>, pollingIntervalMs:
258273
): (...args: Parameters<T>) => Promise<void> => {
259274
return async function (...args: Parameters<T>): Promise<void> {
260275
if ((target as any).isRunning) return;
261-
276+
262277
(target as any).isRunning = true;
263278
try {
264279
await target(...args);

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8125,6 +8125,7 @@ __metadata:
81258125
gitea-js: "npm:^1.22.0"
81268126
glob: "npm:^11.1.0"
81278127
groupmq: "npm:^1.0.0"
8128+
http-status-codes: "npm:^2.3.0"
81288129
ioredis: "npm:^5.4.2"
81298130
json-schema-to-typescript: "npm:^15.0.4"
81308131
lowdb: "npm:^7.0.1"

0 commit comments

Comments
 (0)