diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 8aef94cccd0..917205dd63d 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -1,4 +1,5 @@ import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; +import { withRateLimitRetry } from './rateLimitedClerkClient'; import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable'; import type { Browser, BrowserContext, Page } from '@playwright/test'; @@ -34,7 +35,7 @@ export const createTestUtils = < ): Params extends Partial ? FullReturn : OnlyAppReturn => { const { app, context, browser, useTestingToken = true } = params || {}; - const clerkClient = createClerkClient(app); + const clerkClient = withRateLimitRetry(createClerkClient(app)); const services = { clerk: clerkClient, email: createEmailService(), diff --git a/integration/testUtils/rateLimitedClerkClient.ts b/integration/testUtils/rateLimitedClerkClient.ts new file mode 100644 index 00000000000..eb410f86225 --- /dev/null +++ b/integration/testUtils/rateLimitedClerkClient.ts @@ -0,0 +1,89 @@ +import type { ClerkClient } from '@clerk/backend'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; + +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 1000; +const JITTER_MAX_MS = 500; +const MAX_RETRY_DELAY_MS = 30_000; + +const retryStats = { totalRetries: 0, callsRetried: new Set() }; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getRetryDelay(error: unknown, attempt: number): number { + if (isClerkAPIResponseError(error) && typeof error.retryAfter === 'number') { + return Math.min(error.retryAfter * 1000, MAX_RETRY_DELAY_MS); + } + return BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS; +} + +function recordRetry(path: string): void { + retryStats.totalRetries++; + retryStats.callsRetried.add(path); +} + +export function printRateLimitSummary(): void { + if (retryStats.totalRetries === 0) { + console.log('[Rate Limit] No rate-limit retries occurred during this run.'); + return; + } + const methods = [...retryStats.callsRetried].join(', '); + console.warn( + `[Rate Limit] Summary: ${retryStats.totalRetries} retries across ${retryStats.callsRetried.size} API calls (${methods})`, + ); +} + +async function retryOnRateLimit(firstAttempt: Promise, fn: () => Promise, path: string): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return attempt === 0 ? await firstAttempt : await fn(); + } catch (error) { + const isRateLimited = isClerkAPIResponseError(error) && error.status === 429; + if (!isRateLimited || attempt === MAX_RETRIES) { + throw error; + } + recordRetry(path); + const delayMs = getRetryDelay(error, attempt); + console.warn(`[Rate Limit] Retry ${attempt + 1}/${MAX_RETRIES} for ${path}, waiting ${Math.round(delayMs)}ms`); + await sleep(delayMs); + } + } + // Unreachable, but satisfies TypeScript + throw new Error('Unreachable'); +} + +function createProxy(target: unknown, path: string[] = []): unknown { + if (target === null || (typeof target !== 'object' && typeof target !== 'function')) { + return target; + } + + return new Proxy(target as object, { + get(obj, prop, receiver) { + if (typeof prop === 'symbol') { + return Reflect.get(obj, prop, receiver); + } + const value = Reflect.get(obj, prop, receiver); + if (typeof value === 'function') { + return (...args: unknown[]) => { + const result = value.apply(obj, args); + // Only wrap promises (async API calls), pass through sync returns + if (result && typeof result === 'object' && typeof result.then === 'function') { + const fullPath = [...path, prop].join('.'); + return retryOnRateLimit(result, () => value.apply(obj, args), fullPath); + } + return result; + }; + } + if (typeof value === 'object' && value !== null) { + return createProxy(value, [...path, prop]); + } + return value; + }, + }); +} + +export function withRateLimitRetry(client: ClerkClient): ClerkClient { + return createProxy(client) as ClerkClient; +} diff --git a/integration/tests/global.teardown.ts b/integration/tests/global.teardown.ts index 7445ab191c7..b53af6b421e 100644 --- a/integration/tests/global.teardown.ts +++ b/integration/tests/global.teardown.ts @@ -4,6 +4,7 @@ import { constants } from '../constants'; import { stateFile } from '../models/stateFile'; import { appConfigs } from '../presets'; import { killClerkJsHttpServer, killClerkUiHttpServer, parseEnvOptions } from '../scripts'; +import { printRateLimitSummary } from '../testUtils/rateLimitedClerkClient'; setup('teardown long running apps', async () => { setup.setTimeout(90_000); @@ -27,4 +28,5 @@ setup('teardown long running apps', async () => { } stateFile.remove(); console.log('Long running apps destroyed'); + printRateLimitSummary(); });