From 5e92535cfc7e1bcdf58e9b9c0a579fc1566e28f5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 14 Mar 2026 07:35:25 -0500 Subject: [PATCH 1/5] feat(integration): add rate-limit retry wrapper for Clerk backend client --- .../testUtils/rateLimitedClerkClient.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 integration/testUtils/rateLimitedClerkClient.ts diff --git a/integration/testUtils/rateLimitedClerkClient.ts b/integration/testUtils/rateLimitedClerkClient.ts new file mode 100644 index 00000000000..11f4d323ec2 --- /dev/null +++ b/integration/testUtils/rateLimitedClerkClient.ts @@ -0,0 +1,69 @@ +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; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getRetryDelay(error: unknown, attempt: number): number { + if (isClerkAPIResponseError(error) && error.retryAfter) { + return error.retryAfter * 1000; + } + return BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS; +} + +async function withRetry(fn: () => Promise, path: string): Promise { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (error) { + const isRateLimited = isClerkAPIResponseError(error) && error.status === 429; + if (!isRateLimited || attempt === MAX_RETRIES) { + throw error; + } + 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 withRetry(() => 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; +} From 30a2889702aa5283a8df2f2e51536880f851855b Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 14 Mar 2026 07:35:28 -0500 Subject: [PATCH 2/5] feat(integration): apply rate-limit retry wrapper to BAPI client --- integration/testUtils/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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(), From 6db0806d8092b6150ff8475189ea955342c09bc2 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 14 Mar 2026 07:37:17 -0500 Subject: [PATCH 3/5] fix(integration): avoid double invocation in rate-limit proxy --- integration/testUtils/rateLimitedClerkClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/testUtils/rateLimitedClerkClient.ts b/integration/testUtils/rateLimitedClerkClient.ts index 11f4d323ec2..7277242651c 100644 --- a/integration/testUtils/rateLimitedClerkClient.ts +++ b/integration/testUtils/rateLimitedClerkClient.ts @@ -16,10 +16,10 @@ function getRetryDelay(error: unknown, attempt: number): number { return BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * JITTER_MAX_MS; } -async function withRetry(fn: () => Promise, path: string): Promise { +async function retryOnRateLimit(firstAttempt: Promise, fn: () => Promise, path: string): Promise { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - return await fn(); + return attempt === 0 ? await firstAttempt : await fn(); } catch (error) { const isRateLimited = isClerkAPIResponseError(error) && error.status === 429; if (!isRateLimited || attempt === MAX_RETRIES) { @@ -51,7 +51,7 @@ function createProxy(target: unknown, path: string[] = []): unknown { // 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 withRetry(() => value.apply(obj, args), fullPath); + return retryOnRateLimit(result, () => value.apply(obj, args), fullPath); } return result; }; From c173fb813ea1c58c1f23f17c62bb8ebfda7091a3 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 14 Mar 2026 07:39:37 -0500 Subject: [PATCH 4/5] fix(integration): handle retryAfter edge cases in rate-limit wrapper --- integration/testUtils/rateLimitedClerkClient.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integration/testUtils/rateLimitedClerkClient.ts b/integration/testUtils/rateLimitedClerkClient.ts index 7277242651c..1fec079c7d6 100644 --- a/integration/testUtils/rateLimitedClerkClient.ts +++ b/integration/testUtils/rateLimitedClerkClient.ts @@ -4,14 +4,15 @@ 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; function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } function getRetryDelay(error: unknown, attempt: number): number { - if (isClerkAPIResponseError(error) && error.retryAfter) { - return error.retryAfter * 1000; + 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; } From da6b99883a944ed8e864c0319771d4ca5a7366ba Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 14 Mar 2026 07:57:35 -0500 Subject: [PATCH 5/5] feat(e2e): add rate-limit retry summary to teardown output --- .../testUtils/rateLimitedClerkClient.ts | 19 +++++++++++++++++++ integration/tests/global.teardown.ts | 2 ++ 2 files changed, 21 insertions(+) diff --git a/integration/testUtils/rateLimitedClerkClient.ts b/integration/testUtils/rateLimitedClerkClient.ts index 1fec079c7d6..eb410f86225 100644 --- a/integration/testUtils/rateLimitedClerkClient.ts +++ b/integration/testUtils/rateLimitedClerkClient.ts @@ -6,6 +6,8 @@ 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)); } @@ -17,6 +19,22 @@ function getRetryDelay(error: unknown, attempt: number): number { 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 { @@ -26,6 +44,7 @@ async function retryOnRateLimit(firstAttempt: Promise, fn: () => Promise { setup.setTimeout(90_000); @@ -27,4 +28,5 @@ setup('teardown long running apps', async () => { } stateFile.remove(); console.log('Long running apps destroyed'); + printRateLimitSummary(); });