Skip to content

Commit 829583a

Browse files
authored
fix(testing): add retry logic for testing token fetch on 429/5xx (#8138)
1 parent 46d0a6d commit 829583a

File tree

5 files changed

+259
-2
lines changed

5 files changed

+259
-2
lines changed

.changeset/retry-testing-token.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/testing": patch
3+
---
4+
5+
Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.

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

packages/testing/src/common/setup.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
11
import { createClerkClient } from '@clerk/backend';
2+
import { isClerkAPIResponseError } from '@clerk/shared/error';
23
import { parsePublishableKey } from '@clerk/shared/keys';
34
import dotenv from 'dotenv';
45

56
import type { ClerkSetupOptions, ClerkSetupReturn } from './types';
67

8+
const MAX_RETRIES = 5;
9+
const BASE_DELAY_MS = 1000;
10+
const JITTER_MAX_MS = 500;
11+
const MAX_RETRY_DELAY_MS = 30_000;
12+
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
13+
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);
14+
15+
function isNetworkError(error: unknown): boolean {
16+
return (
17+
error instanceof Error &&
18+
'code' in error &&
19+
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
20+
);
21+
}
22+
23+
async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
24+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
25+
try {
26+
return await fn();
27+
} catch (error) {
28+
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
29+
const isRetryableNetwork = isNetworkError(error);
30+
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
31+
throw error;
32+
}
33+
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
34+
const delay =
35+
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
36+
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
37+
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
38+
console.warn(
39+
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
40+
);
41+
await new Promise(resolve => setTimeout(resolve, delay));
42+
}
43+
}
44+
throw new Error('Unreachable');
45+
}
46+
747
export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
848
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};
949

@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
4484
try {
4585
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
4686
const clerkClient = createClerkClient({ secretKey, apiUrl });
47-
const tokenData = await clerkClient.testingTokens.createTestingToken();
87+
const tokenData = await fetchWithRetry(
88+
() => clerkClient.testingTokens.createTestingToken(),
89+
'testingTokens.createTestingToken',
90+
);
4891
testingToken = tokenData.token;
4992
} catch (err) {
5093
console.error('Failed to fetch testing token from Clerk API.');

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)