Skip to content

Commit 777386e

Browse files
committed
test(testing): add unit tests for fetchWithRetry retry logic
1 parent 4cf7693 commit 777386e

3 files changed

Lines changed: 209 additions & 1 deletion

File tree

packages/testing/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"dev:pub": "pnpm dev -- --env.publish",
7373
"format": "node ../../scripts/format-package.mjs",
7474
"format:check": "node ../../scripts/format-package.mjs --check",
75-
"lint": "eslint src"
75+
"lint": "eslint src",
76+
"test": "vitest"
7677
},
7778
"dependencies": {
7879
"@clerk/backend": "workspace:^",
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { ClerkAPIResponseError } from '@clerk/shared/error';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
// Re-export internals for testing by importing the module and testing through fetchEnvVars
5+
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
6+
// and also directly by extracting them via a test-specific import approach.
7+
8+
// We need to mock the dependencies before importing the module under test
9+
vi.mock('@clerk/backend', () => ({
10+
createClerkClient: vi.fn(),
11+
}));
12+
13+
vi.mock('dotenv', () => ({
14+
default: { config: vi.fn() },
15+
}));
16+
17+
vi.mock('@clerk/shared/keys', () => ({
18+
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
19+
}));
20+
21+
import { createClerkClient } from '@clerk/backend';
22+
import { fetchEnvVars } from '../setup';
23+
24+
function createClerkAPIError(status: number, retryAfter?: number) {
25+
return new ClerkAPIResponseError('API error', {
26+
data: [],
27+
status,
28+
retryAfter,
29+
});
30+
}
31+
32+
function createNetworkError(code: string) {
33+
const err = new Error(`connect ${code}`);
34+
(err as NodeJS.ErrnoException).code = code;
35+
return err;
36+
}
37+
38+
describe('fetchWithRetry (via fetchEnvVars)', () => {
39+
const mockCreateTestingToken = vi.fn();
40+
41+
beforeEach(() => {
42+
vi.useFakeTimers();
43+
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
44+
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
45+
delete process.env.CLERK_TESTING_TOKEN;
46+
47+
vi.mocked(createClerkClient).mockReturnValue({
48+
testingTokens: { createTestingToken: mockCreateTestingToken },
49+
} as any);
50+
});
51+
52+
afterEach(() => {
53+
vi.useRealTimers();
54+
vi.unstubAllEnvs();
55+
vi.restoreAllMocks();
56+
});
57+
58+
it('returns on first success without retrying', async () => {
59+
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });
60+
61+
const result = await fetchEnvVars({ dotenv: false });
62+
63+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
64+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
65+
});
66+
67+
it('retries on 429 and succeeds', async () => {
68+
mockCreateTestingToken
69+
.mockRejectedValueOnce(createClerkAPIError(429))
70+
.mockResolvedValueOnce({ token: 'test-token' });
71+
72+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
73+
const promise = fetchEnvVars({ dotenv: false });
74+
await vi.advanceTimersByTimeAsync(30_000);
75+
const result = await promise;
76+
77+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
78+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
79+
expect(warnSpy).toHaveBeenCalledTimes(1);
80+
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
81+
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
82+
});
83+
84+
it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
85+
mockCreateTestingToken
86+
.mockRejectedValueOnce(createClerkAPIError(status))
87+
.mockResolvedValueOnce({ token: 'test-token' });
88+
89+
vi.spyOn(console, 'warn').mockImplementation(() => {});
90+
const promise = fetchEnvVars({ dotenv: false });
91+
await vi.advanceTimersByTimeAsync(30_000);
92+
const result = await promise;
93+
94+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
95+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
96+
});
97+
98+
it('does not retry on non-retryable status codes', async () => {
99+
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
100+
vi.spyOn(console, 'error').mockImplementation(() => {});
101+
102+
await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
103+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
104+
});
105+
106+
it('throws after max retries exhausted', async () => {
107+
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));
108+
109+
vi.spyOn(console, 'warn').mockImplementation(() => {});
110+
vi.spyOn(console, 'error').mockImplementation(() => {});
111+
112+
const promise = fetchEnvVars({ dotenv: false }).catch(e => e);
113+
114+
await vi.runAllTimersAsync();
115+
116+
const error = await promise;
117+
expect(error).toBeInstanceOf(ClerkAPIResponseError);
118+
expect(error.status).toBe(429);
119+
// 1 initial + 5 retries = 6 total calls
120+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
121+
});
122+
123+
it('uses retryAfter from error when available', async () => {
124+
mockCreateTestingToken
125+
.mockRejectedValueOnce(createClerkAPIError(429, 2))
126+
.mockResolvedValueOnce({ token: 'test-token' });
127+
128+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
129+
const promise = fetchEnvVars({ dotenv: false });
130+
131+
// retryAfter is 2 seconds = 2000ms
132+
await vi.advanceTimersByTimeAsync(2000);
133+
const result = await promise;
134+
135+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
136+
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
137+
});
138+
139+
it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
140+
mockCreateTestingToken
141+
.mockRejectedValueOnce(createClerkAPIError(429, 60))
142+
.mockResolvedValueOnce({ token: 'test-token' });
143+
144+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
145+
const promise = fetchEnvVars({ dotenv: false });
146+
await vi.advanceTimersByTimeAsync(30_000);
147+
const result = await promise;
148+
149+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
150+
// 60s * 1000 = 60000ms, capped to 30000ms
151+
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
152+
});
153+
154+
it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
155+
'retries on network error %s',
156+
async code => {
157+
mockCreateTestingToken
158+
.mockRejectedValueOnce(createNetworkError(code))
159+
.mockResolvedValueOnce({ token: 'test-token' });
160+
161+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
162+
const promise = fetchEnvVars({ dotenv: false });
163+
await vi.advanceTimersByTimeAsync(30_000);
164+
const result = await promise;
165+
166+
expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
167+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
168+
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
169+
},
170+
);
171+
172+
it('does not retry on non-network errors', async () => {
173+
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
174+
vi.spyOn(console, 'error').mockImplementation(() => {});
175+
176+
await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
177+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
178+
});
179+
180+
it('does not retry when non-retryable error code is present', async () => {
181+
const err = new Error('unknown');
182+
(err as NodeJS.ErrnoException).code = 'EPERM';
183+
vi.spyOn(console, 'error').mockImplementation(() => {});
184+
185+
mockCreateTestingToken.mockRejectedValueOnce(err);
186+
187+
await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
188+
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
189+
});
190+
191+
it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
192+
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');
193+
194+
const result = await fetchEnvVars({ dotenv: false });
195+
196+
expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
197+
expect(mockCreateTestingToken).not.toHaveBeenCalled();
198+
});
199+
});

packages/testing/vitest.config.mts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
watch: false,
6+
include: ['**/*.{test,spec}.{ts,tsx}'],
7+
},
8+
});

0 commit comments

Comments
 (0)