Skip to content

Commit 94e9165

Browse files
authored
feat(e2e): add retry for transient BAPI errors in integration tests (#8081)
1 parent 5c02b89 commit 94e9165

File tree

4 files changed

+349
-1
lines changed

4 files changed

+349
-1
lines changed
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import type { ClerkClient } from '@clerk/backend';
2+
import { ClerkAPIResponseError } from '@clerk/shared/error';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { printRetrySummary, withRetry } from '../retryableClerkClient';
6+
7+
function makeClerkAPIError(status: number, opts?: { retryAfter?: number }): ClerkAPIResponseError {
8+
return new ClerkAPIResponseError('API error', {
9+
data: [],
10+
status,
11+
...(opts?.retryAfter != null ? { retryAfter: opts.retryAfter } : {}),
12+
});
13+
}
14+
15+
/**
16+
* Returns a mock that rejects via a deferred microtask instead of returning a
17+
* pre-rejected promise. This avoids Node's PromiseRejectionHandledWarning:
18+
* the proxy's createProxy calls value.apply() to get a promise, then passes it
19+
* to retryOnFailure which awaits it — but with an already-rejected promise
20+
* there's a tiny gap before the await handler is registered.
21+
*/
22+
function mockDeferredReject(error: Error) {
23+
return vi.fn(() => Promise.resolve().then(() => Promise.reject(error)));
24+
}
25+
26+
function makeMockClient(overrides: Record<string, unknown> = {}) {
27+
return {
28+
users: {
29+
getUser: vi.fn(),
30+
deleteUser: vi.fn(),
31+
syncValue: vi.fn(() => 'sync-result'),
32+
...overrides,
33+
},
34+
} as unknown as ClerkClient;
35+
}
36+
37+
describe('withRetry', () => {
38+
beforeEach(() => {
39+
vi.useFakeTimers();
40+
vi.spyOn(console, 'warn').mockImplementation(() => {});
41+
vi.spyOn(console, 'log').mockImplementation(() => {});
42+
});
43+
44+
afterEach(() => {
45+
vi.useRealTimers();
46+
vi.restoreAllMocks();
47+
});
48+
49+
describe('retryOnFailure — retryable status codes', () => {
50+
it.each([429, 502, 503, 504])('retries on status %d up to MAX_RETRIES then throws', async status => {
51+
const error = makeClerkAPIError(status);
52+
const mock = mockDeferredReject(error);
53+
const client = makeMockClient({ getUser: mock });
54+
const wrapped = withRetry(client);
55+
56+
const promise = (wrapped.users as any).getUser('user_123');
57+
58+
// Attach handler before advancing timers to avoid unhandled rejection
59+
const expectation = expect(promise).rejects.toBe(error);
60+
61+
// Advance through all 6 attempts (initial + 5 retries)
62+
for (let i = 0; i < 6; i++) {
63+
await vi.advanceTimersByTimeAsync(60_000);
64+
}
65+
66+
await expectation;
67+
68+
// 1 initial call + 5 retries = 6 total
69+
expect(mock).toHaveBeenCalledTimes(6);
70+
});
71+
72+
it('succeeds on retry after transient failure', async () => {
73+
const error = makeClerkAPIError(429);
74+
const mock = vi
75+
.fn()
76+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
77+
.mockResolvedValueOnce({ id: 'user_123' });
78+
const client = makeMockClient({ getUser: mock });
79+
const wrapped = withRetry(client);
80+
81+
const promise = (wrapped.users as any).getUser('user_123');
82+
83+
await vi.advanceTimersByTimeAsync(60_000);
84+
85+
await expect(promise).resolves.toEqual({ id: 'user_123' });
86+
expect(mock).toHaveBeenCalledTimes(2);
87+
});
88+
});
89+
90+
describe('retryOnFailure — non-retryable status codes', () => {
91+
it.each([400, 401, 403, 404, 500])('does not retry on status %d', async status => {
92+
const error = makeClerkAPIError(status);
93+
const mock = mockDeferredReject(error);
94+
const client = makeMockClient({ getUser: mock });
95+
const wrapped = withRetry(client);
96+
97+
await expect((wrapped.users as any).getUser('user_123')).rejects.toBe(error);
98+
99+
// Only the initial call, no retries
100+
expect(mock).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('does not retry on non-ClerkAPIResponseError', async () => {
104+
const error = new Error('network failure');
105+
const mock = mockDeferredReject(error);
106+
const client = makeMockClient({ getUser: mock });
107+
const wrapped = withRetry(client);
108+
109+
await expect((wrapped.users as any).getUser('user_123')).rejects.toBe(error);
110+
expect(mock).toHaveBeenCalledTimes(1);
111+
});
112+
});
113+
114+
describe('getRetryDelay — retryAfter', () => {
115+
it('uses retryAfter seconds from the error for the delay', async () => {
116+
const error = makeClerkAPIError(429, { retryAfter: 3 });
117+
const mock = vi
118+
.fn()
119+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
120+
.mockResolvedValueOnce({ id: 'user_123' });
121+
const client = makeMockClient({ getUser: mock });
122+
const wrapped = withRetry(client);
123+
124+
const promise = (wrapped.users as any).getUser('user_123');
125+
126+
// retryAfter=3 means 3000ms delay. Advancing 2999ms should not resolve the retry.
127+
await vi.advanceTimersByTimeAsync(2999);
128+
expect(mock).toHaveBeenCalledTimes(1);
129+
130+
// Advancing past the 3000ms mark triggers the retry
131+
await vi.advanceTimersByTimeAsync(1);
132+
await vi.advanceTimersByTimeAsync(0);
133+
134+
await expect(promise).resolves.toEqual({ id: 'user_123' });
135+
expect(mock).toHaveBeenCalledTimes(2);
136+
});
137+
138+
it('caps retryAfter delay at MAX_RETRY_DELAY_MS (30s)', async () => {
139+
const error = makeClerkAPIError(429, { retryAfter: 60 });
140+
const mock = vi
141+
.fn()
142+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
143+
.mockResolvedValueOnce({ id: 'user_123' });
144+
const client = makeMockClient({ getUser: mock });
145+
const wrapped = withRetry(client);
146+
147+
const promise = (wrapped.users as any).getUser('user_123');
148+
149+
// Even though retryAfter is 60s, delay should be capped at 30s
150+
await vi.advanceTimersByTimeAsync(30_000);
151+
await vi.advanceTimersByTimeAsync(0);
152+
153+
await expect(promise).resolves.toEqual({ id: 'user_123' });
154+
expect(mock).toHaveBeenCalledTimes(2);
155+
});
156+
});
157+
158+
describe('createProxy — synchronous methods', () => {
159+
it('passes through synchronous (non-Promise) return values unwrapped', () => {
160+
const syncFn = vi.fn(() => 'sync-result');
161+
const client = makeMockClient({ syncValue: syncFn });
162+
const wrapped = withRetry(client);
163+
164+
const result = (wrapped.users as any).syncValue();
165+
166+
expect(result).toBe('sync-result');
167+
expect(syncFn).toHaveBeenCalledTimes(1);
168+
});
169+
170+
it('passes through non-function properties', () => {
171+
const client = { users: { count: 42 } } as unknown as ClerkClient;
172+
const wrapped = withRetry(client);
173+
174+
expect((wrapped.users as any).count).toBe(42);
175+
});
176+
177+
it('passes through nested object access', () => {
178+
const mock = vi.fn().mockResolvedValue({ id: 'user_123' });
179+
const client = { deeply: { nested: { getUser: mock } } } as unknown as ClerkClient;
180+
const wrapped = withRetry(client);
181+
182+
expect(typeof (wrapped as any).deeply.nested.getUser).toBe('function');
183+
});
184+
});
185+
186+
describe('printRetrySummary', () => {
187+
it('logs no-retries message when no retries occurred', () => {
188+
const mock = vi.fn().mockResolvedValue({ id: 'user_123' });
189+
const client = makeMockClient({ getUser: mock });
190+
withRetry(client);
191+
192+
// printRetrySummary uses module-level retryStats. In a fresh run with no
193+
// retries it logs "No retries"; after retries from earlier tests it logs
194+
// a summary. Either way it produces a [Retry] message.
195+
printRetrySummary();
196+
197+
const logCalled = (console.log as any).mock.calls.some((args: string[]) => args[0]?.includes('[Retry]'));
198+
const warnCalled = (console.warn as any).mock.calls.some((args: string[]) =>
199+
args[0]?.includes('[Retry] Summary'),
200+
);
201+
expect(logCalled || warnCalled).toBe(true);
202+
});
203+
204+
it('logs retry summary after retries have occurred', async () => {
205+
const error = makeClerkAPIError(429);
206+
const mock = vi
207+
.fn()
208+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
209+
.mockResolvedValueOnce({ id: 'user_123' });
210+
const client = makeMockClient({ getUser: mock });
211+
const wrapped = withRetry(client);
212+
213+
const promise = (wrapped.users as any).getUser('user_123');
214+
await vi.advanceTimersByTimeAsync(60_000);
215+
await promise;
216+
217+
// After a retry, console.warn should have been called with retry info
218+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('[Retry]'));
219+
220+
printRetrySummary();
221+
222+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('[Retry] Summary'));
223+
});
224+
});
225+
226+
describe('console.warn during retries', () => {
227+
it('logs a warning with status, path, and attempt info on each retry', async () => {
228+
const error = makeClerkAPIError(503);
229+
const mock = vi
230+
.fn()
231+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
232+
.mockImplementationOnce(() => Promise.resolve().then(() => Promise.reject(error)))
233+
.mockResolvedValueOnce({ id: 'user_123' });
234+
const client = makeMockClient({ getUser: mock });
235+
const wrapped = withRetry(client);
236+
237+
const promise = (wrapped.users as any).getUser('user_123');
238+
239+
await vi.advanceTimersByTimeAsync(60_000);
240+
await vi.advanceTimersByTimeAsync(60_000);
241+
242+
await promise;
243+
244+
const warnCalls = (console.warn as any).mock.calls.map((args: string[]) => args[0]);
245+
const retryCalls = warnCalls.filter((msg: string) => msg?.includes('[Retry] 503'));
246+
247+
expect(retryCalls).toHaveLength(2);
248+
expect(retryCalls[0]).toContain('attempt 1/5');
249+
expect(retryCalls[1]).toContain('attempt 2/5');
250+
expect(retryCalls[0]).toContain('users.getUser');
251+
});
252+
});
253+
});

integration/testUtils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createClerkClient as backendCreateClerkClient } from '@clerk/backend';
2+
import { withRetry } from './retryableClerkClient';
23
import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable';
34
import type { Browser, BrowserContext, Page } from '@playwright/test';
45

@@ -34,7 +35,7 @@ export const createTestUtils = <
3435
): Params extends Partial<CreateAppPageObjectArgs> ? FullReturn : OnlyAppReturn => {
3536
const { app, context, browser, useTestingToken = true } = params || {};
3637

37-
const clerkClient = createClerkClient(app);
38+
const clerkClient = withRetry(createClerkClient(app));
3839
const services = {
3940
clerk: clerkClient,
4041
email: createEmailService(),
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ClerkClient } from '@clerk/backend';
2+
import { isClerkAPIResponseError } from '@clerk/shared/error';
3+
4+
const MAX_RETRIES = 5;
5+
const BASE_DELAY_MS = 1000;
6+
const JITTER_MAX_MS = 500;
7+
const MAX_RETRY_DELAY_MS = 30_000;
8+
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
9+
10+
const retryStats = { totalRetries: 0, callsRetried: new Set<string>() };
11+
12+
function sleep(ms: number): Promise<void> {
13+
return new Promise(resolve => setTimeout(resolve, ms));
14+
}
15+
16+
function getRetryDelay(error: unknown, attempt: number): number {
17+
if (isClerkAPIResponseError(error) && typeof error.retryAfter === 'number') {
18+
return Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS);
19+
}
20+
return BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS;
21+
}
22+
23+
function recordRetry(path: string): void {
24+
retryStats.totalRetries++;
25+
retryStats.callsRetried.add(path);
26+
}
27+
28+
export function printRetrySummary(): void {
29+
if (retryStats.totalRetries === 0) {
30+
console.log('[Retry] No retries occurred during this run.');
31+
return;
32+
}
33+
const methods = [...retryStats.callsRetried].join(', ');
34+
console.warn(
35+
`[Retry] Summary: ${retryStats.totalRetries} retries across ${retryStats.callsRetried.size} API calls (${methods})`,
36+
);
37+
}
38+
39+
async function retryOnFailure<T>(firstAttempt: Promise<T>, fn: () => Promise<T>, path: string): Promise<T> {
40+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
41+
try {
42+
return attempt === 0 ? await firstAttempt : await fn();
43+
} catch (error) {
44+
const isRetryable = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
45+
if (!isRetryable || attempt === MAX_RETRIES) {
46+
throw error;
47+
}
48+
recordRetry(path);
49+
const delayMs = getRetryDelay(error, attempt);
50+
console.warn(
51+
`[Retry] ${error.status} for ${path}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delayMs)}ms`,
52+
);
53+
await sleep(delayMs);
54+
}
55+
}
56+
// Unreachable, but satisfies TypeScript
57+
throw new Error('Unreachable');
58+
}
59+
60+
function createProxy(target: unknown, path: string[] = []): unknown {
61+
if (target === null || (typeof target !== 'object' && typeof target !== 'function')) {
62+
return target;
63+
}
64+
65+
return new Proxy(target as object, {
66+
get(obj, prop, receiver) {
67+
if (typeof prop === 'symbol') {
68+
return Reflect.get(obj, prop, receiver);
69+
}
70+
const value = Reflect.get(obj, prop, receiver);
71+
if (typeof value === 'function') {
72+
return (...args: unknown[]) => {
73+
const result = value.apply(obj, args);
74+
// Only wrap promises (async API calls), pass through sync returns
75+
if (result && typeof result === 'object' && typeof result.then === 'function') {
76+
const fullPath = [...path, prop].join('.');
77+
return retryOnFailure(result, () => value.apply(obj, args), fullPath);
78+
}
79+
return result;
80+
};
81+
}
82+
if (typeof value === 'object' && value !== null) {
83+
return createProxy(value, [...path, prop]);
84+
}
85+
return value;
86+
},
87+
});
88+
}
89+
90+
export function withRetry(client: ClerkClient): ClerkClient {
91+
return createProxy(client) as ClerkClient;
92+
}

integration/tests/global.teardown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { constants } from '../constants';
44
import { stateFile } from '../models/stateFile';
55
import { appConfigs } from '../presets';
66
import { killClerkJsHttpServer, killClerkUiHttpServer, parseEnvOptions } from '../scripts';
7+
import { printRetrySummary } from '../testUtils/retryableClerkClient';
78

89
setup('teardown long running apps', async () => {
910
setup.setTimeout(90_000);
@@ -27,4 +28,5 @@ setup('teardown long running apps', async () => {
2728
}
2829
stateFile.remove();
2930
console.log('Long running apps destroyed');
31+
printRetrySummary();
3032
});

0 commit comments

Comments
 (0)