Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/retry-testing-token.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@clerk/testing": patch
---

Add retry logic with exponential backoff for testing token fetch on 429 and 5xx responses.
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"dev:pub": "pnpm dev -- --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src"
"lint": "eslint src",
"test": "vitest"
},
"dependencies": {
"@clerk/backend": "workspace:^",
Expand Down
200 changes: 200 additions & 0 deletions packages/testing/src/common/__tests__/setup.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
import { ClerkAPIResponseError } from '@clerk/shared/error';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

// Re-export internals for testing by importing the module and testing through fetchEnvVars
// Since fetchWithRetry and isNetworkError are not exported, we test them indirectly through fetchEnvVars
// and also directly by extracting them via a test-specific import approach.

// We need to mock the dependencies before importing the module under test
vi.mock('@clerk/backend', () => ({
createClerkClient: vi.fn(),
}));

vi.mock('dotenv', () => ({
default: { config: vi.fn() },
}));

vi.mock('@clerk/shared/keys', () => ({
parsePublishableKey: vi.fn(() => ({ frontendApi: 'clerk.test.lcl.dev' })),
}));

import { createClerkClient } from '@clerk/backend';

import { fetchEnvVars } from '../setup';

function createClerkAPIError(status: number, retryAfter?: number) {
return new ClerkAPIResponseError('API error', {
data: [],
status,
retryAfter,
});
}

function createNetworkError(code: string) {
const err = new Error(`connect ${code}`);
(err as NodeJS.ErrnoException).code = code;
return err;
}

describe('fetchWithRetry (via fetchEnvVars)', () => {
const mockCreateTestingToken = vi.fn();

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv('CLERK_PUBLISHABLE_KEY', 'pk_test_abc');
vi.stubEnv('CLERK_SECRET_KEY', 'sk_test_abc');
delete process.env.CLERK_TESTING_TOKEN;

vi.mocked(createClerkClient).mockReturnValue({
testingTokens: { createTestingToken: mockCreateTestingToken },
} as any);
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});

it('returns on first success without retrying', async () => {
mockCreateTestingToken.mockResolvedValueOnce({ token: 'test-token' });

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('retries on 429 and succeeds', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('[Retry] 429');
expect(warnSpy.mock.calls[0][0]).toContain('attempt 1/5');
});

it.each([408, 500, 502, 503, 504])('retries on %i status code', async status => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(status))
.mockResolvedValueOnce({ token: 'test-token' });

vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
});

it('does not retry on non-retryable status codes', async () => {
mockCreateTestingToken.mockRejectedValueOnce(createClerkAPIError(401));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('API error');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('throws after max retries exhausted', async () => {
mockCreateTestingToken.mockImplementation(() => Promise.reject(createClerkAPIError(429)));

vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});

const promise = fetchEnvVars({ dotenv: false }).catch(e => e);

await vi.runAllTimersAsync();

const error = await promise;
expect(error).toBeInstanceOf(ClerkAPIResponseError);
expect(error.status).toBe(429);
// 1 initial + 5 retries = 6 total calls
expect(mockCreateTestingToken).toHaveBeenCalledTimes(6);
});

it('uses retryAfter from error when available', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 2))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });

// retryAfter is 2 seconds = 2000ms
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(warnSpy.mock.calls[0][0]).toContain('waiting 2000ms');
});

it('caps retryAfter delay at MAX_RETRY_DELAY_MS', async () => {
mockCreateTestingToken
.mockRejectedValueOnce(createClerkAPIError(429, 60))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
// 60s * 1000 = 60000ms, capped to 30000ms
expect(warnSpy.mock.calls[0][0]).toContain('waiting 30000ms');
});

it.each(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'])(
'retries on network error %s',
async code => {
mockCreateTestingToken
.mockRejectedValueOnce(createNetworkError(code))
.mockResolvedValueOnce({ token: 'test-token' });

const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const promise = fetchEnvVars({ dotenv: false });
await vi.advanceTimersByTimeAsync(30_000);
const result = await promise;

expect(result.CLERK_TESTING_TOKEN).toBe('test-token');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain(`[Retry] ${code}`);
},
);

it('does not retry on non-network errors', async () => {
mockCreateTestingToken.mockRejectedValueOnce(new TypeError('unexpected'));
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unexpected');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('does not retry when non-retryable error code is present', async () => {
const err = new Error('unknown');
(err as NodeJS.ErrnoException).code = 'EPERM';
vi.spyOn(console, 'error').mockImplementation(() => {});

mockCreateTestingToken.mockRejectedValueOnce(err);

await expect(fetchEnvVars({ dotenv: false })).rejects.toThrow('unknown');
expect(mockCreateTestingToken).toHaveBeenCalledTimes(1);
});

it('skips retry when CLERK_TESTING_TOKEN is already set', async () => {
vi.stubEnv('CLERK_TESTING_TOKEN', 'existing-token');

const result = await fetchEnvVars({ dotenv: false });

expect(result.CLERK_TESTING_TOKEN).toBe('existing-token');
expect(mockCreateTestingToken).not.toHaveBeenCalled();
});
});
45 changes: 44 additions & 1 deletion packages/testing/src/common/setup.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
import { createClerkClient } from '@clerk/backend';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { parsePublishableKey } from '@clerk/shared/keys';
import dotenv from 'dotenv';

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

const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
const JITTER_MAX_MS = 500;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
const RETRYABLE_NETWORK_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN']);

function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
RETRYABLE_NETWORK_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')
);
}

async function fetchWithRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryableApi = isClerkAPIResponseError(error) && RETRYABLE_STATUS_CODES.has(error.status);
const isRetryableNetwork = isNetworkError(error);
if ((!isRetryableApi && !isRetryableNetwork) || attempt === MAX_RETRIES) {
throw error;
}
const status = isClerkAPIResponseError(error) ? error.status : (error as NodeJS.ErrnoException).code;
const delay =
isClerkAPIResponseError(error) && typeof error.retryAfter === 'number'
? Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS)
: Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS, MAX_RETRY_DELAY_MS);
console.warn(
`[Retry] ${status} for ${label}, attempt ${attempt + 1}/${MAX_RETRIES}, waiting ${Math.round(delay)}ms`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSetupReturn> => {
const { debug = false, dotenv: loadDotEnv = true, ...rest } = options || {};

Expand DownExpand Up@@ -44,7 +84,10 @@ export const fetchEnvVars = async (options?: ClerkSetupOptions): Promise<ClerkSe
try {
const apiUrl = (rest as any)?.apiUrl || process.env.CLERK_API_URL;
const clerkClient = createClerkClient({ secretKey, apiUrl });
const tokenData = await clerkClient.testingTokens.createTestingToken();
const tokenData = await fetchWithRetry(
() => clerkClient.testingTokens.createTestingToken(),
'testingTokens.createTestingToken',
);
testingToken = tokenData.token;
} catch (err) {
console.error('Failed to fetch testing token from Clerk API.');
Expand Down
8 changes: 8 additions & 0 deletions packages/testing/vitest.config.mts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
watch: false,
include: ['**/*.{test,spec}.{ts,tsx}'],
},
});
Loading