Skip to content

Commit 4e407b6

Browse files
committed
fix: Retry 502-504 errors 3x with backoff
https://harperdb.atlassian.net/browse/STUDIO-116
1 parent d0902af commit 4e407b6

File tree

3 files changed

+138
-1
lines changed

3 files changed

+138
-1
lines changed

src/config/getInstanceClient.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { apiClient } from '@/config/apiClient';
22
import { authStore, EntityIds, OverallAppSignIn } from '@/features/auth/store/authStore';
33
import { rejectReplicationFailures } from '@/integrations/api/replication';
4+
import { curryRetryGatewayErrors } from '@/integrations/api/retryGatewayErrors';
45
import axios from 'axios';
56

67
interface InstanceClient {
@@ -45,6 +46,9 @@ export function getInstanceClient({ id = OverallAppSignIn, operationsUrl, port,
4546
},
4647
baseURL,
4748
});
48-
client.interceptors.response.use(rejectReplicationFailures);
49+
client.interceptors.response.use(
50+
rejectReplicationFailures,
51+
curryRetryGatewayErrors(client),
52+
);
4953
return client;
5054
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { curryRetryGatewayErrors } from '@/integrations/api/retryGatewayErrors';
2+
import * as sleepModule from '@/lib/sleep';
3+
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('@/lib/sleep', () => ({
7+
sleep: vi.fn().mockResolvedValue(true),
8+
}));
9+
10+
describe('curryRetryGatewayErrors', () => {
11+
let client: Pick<AxiosInstance, 'request'>;
12+
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
client = {
16+
request: vi.fn().mockResolvedValue({ ok: true }),
17+
} as Pick<AxiosInstance, 'request'>;
18+
});
19+
20+
it('should be defined and return a function', () => {
21+
const handler = curryRetryGatewayErrors(client);
22+
expect(handler).toBeDefined();
23+
expect(typeof handler).toBe('function');
24+
});
25+
26+
it('rejects if error has no config', async () => {
27+
const handler = curryRetryGatewayErrors(client);
28+
const err = { response: { status: 502 } } as unknown as { config?: AxiosRequestConfig };
29+
30+
await expect(handler(err)).rejects.toBe(err);
31+
expect(sleepModule.sleep).not.toHaveBeenCalled();
32+
expect(client.request).not.toHaveBeenCalled();
33+
});
34+
35+
it('rejects and does not retry for non-gateway status codes', async () => {
36+
const handler = curryRetryGatewayErrors(client);
37+
const config: AxiosRequestConfig & { __retryCount?: number } = {};
38+
const err = { response: { status: 500 }, config };
39+
40+
await expect(handler(err)).rejects.toBe(err);
41+
expect(sleepModule.sleep).not.toHaveBeenCalled();
42+
expect(client.request).not.toHaveBeenCalled();
43+
// Should not mutate retry count
44+
expect(config.__retryCount).toBeUndefined();
45+
});
46+
47+
it('retries on 502 and increments __retryCount with initial delay on first retry', async () => {
48+
const handler = curryRetryGatewayErrors(client);
49+
const config: AxiosRequestConfig & { __retryCount?: number } = {};
50+
const err = { response: { status: 502 }, config };
51+
52+
const result = await handler(err);
53+
54+
expect(sleepModule.sleep).toHaveBeenCalledTimes(1);
55+
expect(sleepModule.sleep).toHaveBeenCalledWith(5_000);
56+
expect(config.__retryCount).toBe(1);
57+
expect(client.request).toHaveBeenCalledTimes(1);
58+
expect(client.request).toHaveBeenCalledWith(config);
59+
expect(result).toEqual({ ok: true });
60+
});
61+
62+
it('retries on 503 with exponential backoff: more backoff on second retry', async () => {
63+
const handler = curryRetryGatewayErrors(client);
64+
const config: AxiosRequestConfig & { __retryCount?: number } = { __retryCount: 1 };
65+
const err = { response: { status: 503 }, config };
66+
67+
await handler(err);
68+
69+
expect(sleepModule.sleep).toHaveBeenCalledTimes(1);
70+
expect(sleepModule.sleep).toHaveBeenCalledWith(10_000);
71+
expect(config.__retryCount).toBe(2);
72+
expect(client.request).toHaveBeenCalledTimes(1);
73+
});
74+
75+
it('retries on 504 with the largest backoff on third retry', async () => {
76+
const handler = curryRetryGatewayErrors(client);
77+
const config: AxiosRequestConfig & { __retryCount?: number } = { __retryCount: 2 };
78+
const err = { response: { status: 504 }, config };
79+
80+
await handler(err);
81+
82+
expect(sleepModule.sleep).toHaveBeenCalledTimes(1);
83+
expect(sleepModule.sleep).toHaveBeenCalledWith(20_000);
84+
expect(config.__retryCount).toBe(3);
85+
expect(client.request).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('stops retrying when max retries reached (>= 3)', async () => {
89+
const handler = curryRetryGatewayErrors(client);
90+
const config: AxiosRequestConfig & { __retryCount?: number } = { __retryCount: 3 };
91+
const err = { response: { status: 502 }, config };
92+
93+
await expect(handler(err)).rejects.toBe(err);
94+
expect(sleepModule.sleep).not.toHaveBeenCalled();
95+
expect(client.request).not.toHaveBeenCalled();
96+
// Should not change the retry count when rejecting due to max retries
97+
expect(config.__retryCount).toBe(3);
98+
});
99+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { sleep } from '@/lib/sleep';
2+
import { AxiosInstance } from 'axios';
3+
4+
export function curryRetryGatewayErrors(client: Pick<AxiosInstance, 'request'>) {
5+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6+
return async (error: any) => {
7+
const status = error?.response?.status as number | undefined;
8+
const config = error?.config;
9+
if (!config) {
10+
return Promise.reject(error);
11+
}
12+
13+
const shouldRetry = status === 502 || status === 503 || status === 504;
14+
if (!shouldRetry) {
15+
return Promise.reject(error);
16+
}
17+
18+
const maxRetries = 3;
19+
config.__retryCount = config.__retryCount || 0;
20+
if (config.__retryCount >= maxRetries) {
21+
return Promise.reject(error);
22+
}
23+
24+
config.__retryCount += 1;
25+
26+
// 1x, 2x, 4x
27+
const retryPower = Math.pow(2, config.__retryCount - 1);
28+
// 5s, 10s, 20s
29+
const delay = 5_000 * retryPower;
30+
await sleep(delay);
31+
32+
return client.request(config);
33+
};
34+
}

0 commit comments

Comments
 (0)