From 3a4ea451a5fe5e343c8f17d5bcaf9aea99a22c7c Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 09:58:57 -0600 Subject: [PATCH 01/10] fix(clerk-js): Do not treat 429 responses as authentication failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 429 (Too Many Requests) responses were being caught by `is4xxError` and triggering `handleUnauthenticated`, which signs the user out. This caused a cascading failure: the unauthenticated flow calls `/touch`, which returns 429 due to rate limiting, which triggers `handleUnauthenticated` again — creating an infinite retry loop that forces users to re-authenticate even though their session is still valid. Changes: - Add `is429Error` helper to `@clerk/shared` - Exclude 429 from triggering `handleUnauthenticated` in `AuthCookieService.handleGetTokenError`, `Clerk.setActive`, and `Clerk.#touchCurrentSession` - Allow 429 to be retried in `Session.getToken` instead of immediately failing (was blocked by `is4xxError` check in `shouldRetry`) Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-429-unauthenticated.md | 6 +++ .../src/core/auth/AuthCookieService.ts | 13 ++++-- packages/clerk-js/src/core/clerk.ts | 7 ++-- .../clerk-js/src/core/resources/Session.ts | 11 ++++- packages/shared/src/__tests__/error.spec.ts | 40 ++++++++++++++++++- packages/shared/src/error.ts | 1 + packages/shared/src/errors/helpers.ts | 9 +++++ 7 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-429-unauthenticated.md diff --git a/.changeset/fix-429-unauthenticated.md b/.changeset/fix-429-unauthenticated.md new file mode 100644 index 00000000000..264d8f4376d --- /dev/null +++ b/.changeset/fix-429-unauthenticated.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +--- + +Fix 429 (rate limited) responses being incorrectly treated as authentication failures, which caused users to be signed out when the API was rate limiting requests. Rate limited responses now trigger a degraded status and retry instead of the unauthenticated flow. diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index a20a3cd4635..c2d3282cb32 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -3,7 +3,13 @@ import type { createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { clerkEvents } from '@clerk/shared/clerkEventBus'; import type { createCookieHandler } from '@clerk/shared/cookie'; import { setDevBrowserInURL } from '@clerk/shared/devBrowser'; -import { is4xxError, isClerkAPIResponseError, isClerkRuntimeError, isNetworkError } from '@clerk/shared/error'; +import { + is429Error, + is4xxError, + isClerkAPIResponseError, + isClerkRuntimeError, + isNetworkError, +} from '@clerk/shared/error'; import type { Clerk, InstanceType } from '@clerk/shared/types'; import { noop } from '@clerk/shared/utils'; @@ -215,8 +221,9 @@ export class AuthCookieService { return; } - //sign user out if a 4XX error - if (is4xxError(e)) { + // 429 (rate limited) is not an auth failure — the session may still be valid. + // Treat it the same as a transient error and degrade gracefully. + if (is4xxError(e) && !is429Error(e)) { void this.clerk.handleUnauthenticated().catch(noop); return; } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b6ac5321a41..54904a54b21 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -5,6 +5,7 @@ import { ClerkRuntimeError, EmailLinkError, EmailLinkErrorCodeStatus, + is429Error, is4xxError, isClerkAPIResponseError, isClerkRuntimeError, @@ -1595,9 +1596,9 @@ export class Clerk implements ClerkInterface { this.updateClient(updatedClient, { __internal_dangerouslySkipEmit: true }); } } catch (e) { - if (is4xxError(e)) { + if (is4xxError(e) && !is429Error(e)) { void this.handleUnauthenticated(); - } else { + } else if (!is429Error(e)) { throw e; } } @@ -3174,7 +3175,7 @@ export class Clerk implements ClerkInterface { } await session.touch().catch(e => { - if (is4xxError(e)) { + if (is4xxError(e) && !is429Error(e)) { void this.handleUnauthenticated(); } }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index f839def8c18..bafa7e9945e 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,6 +1,12 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import { isValidBrowserOnline } from '@clerk/shared/browser'; -import { ClerkOfflineError, ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { + ClerkOfflineError, + ClerkWebAuthnError, + is429Error, + is4xxError, + MissingExpiredTokenError, +} from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -156,7 +162,8 @@ export class Session extends BaseResource implements SessionResource { maxDelayBetweenRetries: 50 * 1_000, jitter: false, shouldRetry: (error, iterationsCount) => { - if (is4xxError(error)) { + // 429 is a rate limit, not an auth error — retry with backoff + if (is4xxError(error) && !is429Error(error)) { return false; } diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index 98fe6a85324..1e4d512dab6 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest'; import type { ErrorThrowerOptions } from '../error'; -import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error'; +import { + buildErrorThrower, + ClerkOfflineError, + ClerkRuntimeError, + is429Error, + is4xxError, + isClerkRuntimeError, +} from '../error'; describe('ErrorThrower', () => { const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' }); @@ -63,6 +70,37 @@ describe('ClerkRuntimeError', () => { }); }); +describe('is4xxError', () => { + it('returns true for 4xx status codes', () => { + expect(is4xxError({ status: 400 })).toBe(true); + expect(is4xxError({ status: 401 })).toBe(true); + expect(is4xxError({ status: 429 })).toBe(true); + expect(is4xxError({ status: 499 })).toBe(true); + }); + + it('returns false for non-4xx status codes', () => { + expect(is4xxError({ status: 200 })).toBe(false); + expect(is4xxError({ status: 500 })).toBe(false); + expect(is4xxError({})).toBe(false); + expect(is4xxError(null)).toBe(false); + }); +}); + +describe('is429Error', () => { + it('returns true for 429 status', () => { + expect(is429Error({ status: 429 })).toBe(true); + }); + + it('returns false for other status codes', () => { + expect(is429Error({ status: 400 })).toBe(false); + expect(is429Error({ status: 401 })).toBe(false); + expect(is429Error({ status: 500 })).toBe(false); + expect(is429Error({})).toBe(false); + expect(is429Error(null)).toBe(false); + expect(is429Error(undefined)).toBe(false); + }); +}); + describe('ClerkOfflineError', () => { it('is an instance of ClerkRuntimeError', () => { const error = new ClerkOfflineError('Network request failed'); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index b75e569a5db..0bcbe21db36 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -17,6 +17,7 @@ export { ClerkRuntimeError, isClerkRuntimeError } from './errors/clerkRuntimeErr export { ClerkWebAuthnError } from './errors/webAuthNError'; export { + is429Error, is4xxError, isCaptchaError, isEmailLinkError, diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index b95f55d5a8c..708fb6e3e83 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -37,6 +37,15 @@ export function is4xxError(e: any): boolean { return !!status && status >= 400 && status < 500; } +/** + * Checks if the provided error is a 429 (Too Many Requests) error. + * + * @internal + */ +export function is429Error(e: any): boolean { + return e?.status === 429; +} + /** * Checks if the provided error is a network error. * From 06e16c9e4b5d3df5f4eaeeefc4d30adace84ab5d Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 10:03:34 -0600 Subject: [PATCH 02/10] refactor: Replace is4xxError+is429Error with isUnauthenticatedError helper Introduce isUnauthenticatedError that encapsulates the "4xx but not 429" logic in one place, making the intent self-documenting and preventing future callers from accidentally treating rate limits as auth failures. Co-Authored-By: Claude Opus 4.6 --- .../src/core/auth/AuthCookieService.ts | 7 ++---- packages/clerk-js/src/core/clerk.ts | 10 +++++---- .../clerk-js/src/core/resources/Session.ts | 6 ++--- packages/shared/src/__tests__/error.spec.ts | 22 +++++++++++++++++++ packages/shared/src/error.ts | 1 + packages/shared/src/errors/helpers.ts | 16 ++++++++++++++ 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index c2d3282cb32..7a384a70866 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -4,11 +4,10 @@ import { clerkEvents } from '@clerk/shared/clerkEventBus'; import type { createCookieHandler } from '@clerk/shared/cookie'; import { setDevBrowserInURL } from '@clerk/shared/devBrowser'; import { - is429Error, - is4xxError, isClerkAPIResponseError, isClerkRuntimeError, isNetworkError, + isUnauthenticatedError, } from '@clerk/shared/error'; import type { Clerk, InstanceType } from '@clerk/shared/types'; import { noop } from '@clerk/shared/utils'; @@ -221,9 +220,7 @@ export class AuthCookieService { return; } - // 429 (rate limited) is not an auth failure — the session may still be valid. - // Treat it the same as a transient error and degrade gracefully. - if (is4xxError(e) && !is429Error(e)) { + if (isUnauthenticatedError(e)) { void this.clerk.handleUnauthenticated().catch(noop); return; } diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 54904a54b21..d0ec8f72787 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -5,10 +5,10 @@ import { ClerkRuntimeError, EmailLinkError, EmailLinkErrorCodeStatus, - is429Error, is4xxError, isClerkAPIResponseError, isClerkRuntimeError, + isUnauthenticatedError, } from '@clerk/shared/error'; import { disabledAllAPIKeysFeatures, @@ -1596,9 +1596,11 @@ export class Clerk implements ClerkInterface { this.updateClient(updatedClient, { __internal_dangerouslySkipEmit: true }); } } catch (e) { - if (is4xxError(e) && !is429Error(e)) { + if (isUnauthenticatedError(e)) { void this.handleUnauthenticated(); - } else if (!is429Error(e)) { + } else if (!is4xxError(e)) { + // Swallow 4xx errors like 429 (rate limit) that are not auth failures. + // Non-4xx errors (5xx, network) should still propagate. throw e; } } @@ -3175,7 +3177,7 @@ export class Clerk implements ClerkInterface { } await session.touch().catch(e => { - if (is4xxError(e) && !is429Error(e)) { + if (isUnauthenticatedError(e)) { void this.handleUnauthenticated(); } }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index bafa7e9945e..41e6c316eda 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -3,8 +3,7 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkOfflineError, ClerkWebAuthnError, - is429Error, - is4xxError, + isUnauthenticatedError, MissingExpiredTokenError, } from '@clerk/shared/error'; import { @@ -162,8 +161,7 @@ export class Session extends BaseResource implements SessionResource { maxDelayBetweenRetries: 50 * 1_000, jitter: false, shouldRetry: (error, iterationsCount) => { - // 429 is a rate limit, not an auth error — retry with backoff - if (is4xxError(error) && !is429Error(error)) { + if (isUnauthenticatedError(error)) { return false; } diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index 1e4d512dab6..19e33498180 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -8,6 +8,7 @@ import { is429Error, is4xxError, isClerkRuntimeError, + isUnauthenticatedError, } from '../error'; describe('ErrorThrower', () => { @@ -101,6 +102,27 @@ describe('is429Error', () => { }); }); +describe('isUnauthenticatedError', () => { + it('returns true for auth-related 4xx errors', () => { + expect(isUnauthenticatedError({ status: 400 })).toBe(true); + expect(isUnauthenticatedError({ status: 401 })).toBe(true); + expect(isUnauthenticatedError({ status: 403 })).toBe(true); + expect(isUnauthenticatedError({ status: 404 })).toBe(true); + expect(isUnauthenticatedError({ status: 422 })).toBe(true); + }); + + it('returns false for 429 (rate limit)', () => { + expect(isUnauthenticatedError({ status: 429 })).toBe(false); + }); + + it('returns false for non-4xx errors', () => { + expect(isUnauthenticatedError({ status: 200 })).toBe(false); + expect(isUnauthenticatedError({ status: 500 })).toBe(false); + expect(isUnauthenticatedError({})).toBe(false); + expect(isUnauthenticatedError(null)).toBe(false); + }); +}); + describe('ClerkOfflineError', () => { it('is an instance of ClerkRuntimeError', () => { const error = new ClerkOfflineError('Network request failed'); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 0bcbe21db36..37eb5e41bb7 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -27,6 +27,7 @@ export { isPasswordPwnedError, isPasswordCompromisedError, isReverificationCancelledError, + isUnauthenticatedError, isUnauthorizedError, isUserLockedError, } from './errors/helpers'; diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index 708fb6e3e83..d13efc79a92 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -46,6 +46,22 @@ export function is429Error(e: any): boolean { return e?.status === 429; } +/** + * Checks if the provided error indicates the user's session is no longer valid + * and should trigger the unauthenticated flow (e.g. sign-out / redirect to sign-in). + * + * This is a 4xx client error that is NOT a rate limit (429). Rate-limited requests + * are transient — the session may still be valid, so they should be retried rather + * than treated as authentication failures. + * + * Use this instead of `is4xxError` when deciding whether to call `handleUnauthenticated`. + * + * @internal + */ +export function isUnauthenticatedError(e: any): boolean { + return is4xxError(e) && !is429Error(e); +} + /** * Checks if the provided error is a network error. * From 0206f4b5b5a06e3e393b060997b9dd0386a4303d Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 12:08:22 -0600 Subject: [PATCH 03/10] fix(clerk-js): Surface 429 errors from setActive and add integration tests - Let 429 errors from touch propagate to the caller in setActive instead of silently swallowing, so developers can handle rate limits (e.g. show retry UI using error.retryAfter) - Add integration tests for 429 resiliency: user stays signed in, status goes to degraded (not error), and no recursive handleUnauthenticated loop Co-Authored-By: Claude Opus 4.6 --- integration/tests/resiliency.test.ts | 132 +++++++++++++++++++++++++++ packages/clerk-js/src/core/clerk.ts | 6 +- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index db031e34a09..ab842adbcc2 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -4,6 +4,21 @@ import { appConfigs } from '../presets'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +const make429ClerkResponse = () => ({ + status: 429, + headers: { 'retry-after': '1' }, + body: JSON.stringify({ + errors: [ + { + message: 'Too many requests', + long_message: 'Too many requests. Please retry later.', + code: 'rate_limit_exceeded', + }, + ], + clerk_trace_id: 'some-trace-id', + }), +}); + const make500ClerkResponse = () => ({ status: 500, body: JSON.stringify({ @@ -296,6 +311,123 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc }); }); + test.describe('429 rate limit resiliency', () => { + test('signed-in user remains signed in when /tokens returns 429', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Intercept token requests to return 429 + await page.route('**/v1/client/sessions/*/tokens**', route => { + return route.fulfill(make429ClerkResponse()); + }); + + // Clear the token cache so the next poller tick makes a fresh network request + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + // Allow time for the poller to fire and handle the 429 + await page.waitForTimeout(3_000); + + await page.unrouteAll(); + + // The user must still be signed in — 429 is not an auth failure + await u.po.expect.toBeSignedIn(); + }); + + test('setActive surfaces 429 error to the developer instead of silently swallowing', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Intercept touch requests to return 429 + await page.route('**/v1/client/sessions/*/touch**', route => { + return route.fulfill(make429ClerkResponse()); + }); + + // setActive should surface the 429 error so the developer can handle it + const error = await page.evaluate(async () => { + const session = window.Clerk?.session; + if (!session) return null; + try { + await window.Clerk?.setActive({ session }); + return null; + } catch (e: any) { + return { status: e.status, message: e.message }; + } + }); + + expect(error).not.toBeNull(); + expect(error!.status).toBe(429); + + await page.unrouteAll(); + + // The user must still be signed in — 429 should not trigger handleUnauthenticated + await u.po.expect.toBeSignedIn(); + }); + + test('status transitions to degraded (not error) when tokens return 429', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Navigate to status page while signed in + await u.page.goToRelative('/clerk-status'); + await expect(page.getByText('Status: ready', { exact: true })).toBeVisible(); + + // Intercept token requests to return 429 + await page.route('**/v1/client/sessions/*/tokens**', route => { + return route.fulfill(make429ClerkResponse()); + }); + + // Clear the token cache so the next poller tick makes a fresh network request + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + // The app should enter degraded state, not error + await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible({ + timeout: 10_000, + }); + + // Verify the user is still signed in despite degraded state + await page.unrouteAll(); + }); + + test('429 on /tokens does not cause recursive handleUnauthenticated calls', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + let clientRequestCount = 0; + await page.route('**/v1/client?**', route => { + clientRequestCount++; + return route.continue(); + }); + + // Intercept token requests to return 429 + await page.route('**/v1/client/sessions/*/tokens**', route => { + return route.fulfill(make429ClerkResponse()); + }); + + // Clear the token cache so the next poller tick makes a fresh network request + await page.evaluate(() => window.Clerk?.session?.clearCache()); + + await page.waitForTimeout(3_000); + + await page.unrouteAll(); + + // Without the fix, 429 on /tokens would trigger handleUnauthenticated → Client.fetch loop. + // With the fix, /v1/client should only see normal poller activity (not hundreds of requests). + expect(clientRequestCount).toBeLessThan(5); + }); + }); + test.describe('clerk-js script loading', () => { test('recovers from transient network failure on clerk-js script load', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d0ec8f72787..1a362b2edac 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1598,9 +1598,7 @@ export class Clerk implements ClerkInterface { } catch (e) { if (isUnauthenticatedError(e)) { void this.handleUnauthenticated(); - } else if (!is4xxError(e)) { - // Swallow 4xx errors like 429 (rate limit) that are not auth failures. - // Non-4xx errors (5xx, network) should still propagate. + } else { throw e; } } @@ -3179,6 +3177,8 @@ export class Clerk implements ClerkInterface { await session.touch().catch(e => { if (isUnauthenticatedError(e)) { void this.handleUnauthenticated(); + } else { + throw e; } }); }; From 758f30ad2ee79d937377ce5339aa9a5fac598cc2 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 12:51:04 -0600 Subject: [PATCH 04/10] test: Remove flaky degraded-status test for 429 The "status transitions to degraded" test was unreliable because 429 on /tokens triggers retry logic (up to 8 retries) before surfacing the error to handleGetTokenError. The remaining 3 tests cover the critical app-level behavior. Co-Authored-By: Claude Opus 4.6 --- integration/tests/resiliency.test.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index ab842adbcc2..510e5536f90 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -369,34 +369,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await u.po.expect.toBeSignedIn(); }); - test('status transitions to degraded (not error) when tokens return 429', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); - - // Navigate to status page while signed in - await u.page.goToRelative('/clerk-status'); - await expect(page.getByText('Status: ready', { exact: true })).toBeVisible(); - - // Intercept token requests to return 429 - await page.route('**/v1/client/sessions/*/tokens**', route => { - return route.fulfill(make429ClerkResponse()); - }); - - // Clear the token cache so the next poller tick makes a fresh network request - await page.evaluate(() => window.Clerk?.session?.clearCache()); - - // The app should enter degraded state, not error - await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible({ - timeout: 10_000, - }); - - // Verify the user is still signed in despite degraded state - await page.unrouteAll(); - }); - test('429 on /tokens does not cause recursive handleUnauthenticated calls', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); From 5cadb6cffb49cf26081c08843693f78476e3c09f Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 13:10:59 -0600 Subject: [PATCH 05/10] fix(shared): Narrow isUnauthenticatedError to only high-confidence auth failures Only 401 (invalid/expired session) and 422 (invalid session state) should trigger the unauthenticated flow. Other 4xx codes are excluded: - 400: bad request, not an auth failure - 403: authorization issue, session is still valid - 404: ambiguous (session not found vs org/template not found share the same resource_not_found code) - 429: transient rate limit Co-Authored-By: Claude Opus 4.6 --- packages/shared/src/__tests__/error.spec.ts | 10 +++++----- packages/shared/src/errors/helpers.ts | 15 ++++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index 19e33498180..82edc255e18 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -103,15 +103,15 @@ describe('is429Error', () => { }); describe('isUnauthenticatedError', () => { - it('returns true for auth-related 4xx errors', () => { - expect(isUnauthenticatedError({ status: 400 })).toBe(true); + it('returns true for authentication failure status codes', () => { expect(isUnauthenticatedError({ status: 401 })).toBe(true); - expect(isUnauthenticatedError({ status: 403 })).toBe(true); - expect(isUnauthenticatedError({ status: 404 })).toBe(true); expect(isUnauthenticatedError({ status: 422 })).toBe(true); }); - it('returns false for 429 (rate limit)', () => { + it('returns false for other 4xx status codes', () => { + expect(isUnauthenticatedError({ status: 400 })).toBe(false); + expect(isUnauthenticatedError({ status: 403 })).toBe(false); + expect(isUnauthenticatedError({ status: 404 })).toBe(false); expect(isUnauthenticatedError({ status: 429 })).toBe(false); }); diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index d13efc79a92..03566580db3 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -50,16 +50,21 @@ export function is429Error(e: any): boolean { * Checks if the provided error indicates the user's session is no longer valid * and should trigger the unauthenticated flow (e.g. sign-out / redirect to sign-in). * - * This is a 4xx client error that is NOT a rate limit (429). Rate-limited requests - * are transient — the session may still be valid, so they should be retried rather - * than treated as authentication failures. + * Only matches explicit authentication failure status codes: + * - 401: session is invalid or expired + * - 422: invalid session state (e.g. missing_expired_token) * - * Use this instead of `is4xxError` when deciding whether to call `handleUnauthenticated`. + * 404 is intentionally excluded despite being returned for "session not found", + * because it's also returned for unrelated resources (org not found, JWT template + * not found) and shares the same `resource_not_found` error code, making it + * impossible to distinguish. Session-not-found 401s are already handled directly + * by Base._fetch. * * @internal */ export function isUnauthenticatedError(e: any): boolean { - return is4xxError(e) && !is429Error(e); + const status = e?.status; + return status === 401 || status === 422; } /** From 96b3d02d41c3f78870b2b5d44fe9de9a5ab7c8e7 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 13:16:26 -0600 Subject: [PATCH 06/10] fix(clerk-js): Don't retry deterministic 4xx errors in getToken The shouldRetry logic was changed from is4xxError to isUnauthenticatedError, which inadvertently caused deterministic client errors (400, 403, 404) to retry up to 8 times over ~3 minutes. Restore the correct behavior: only retry on transient failures (5xx, network errors, 429 rate limits). Also update changeset description. Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-429-unauthenticated.md | 2 +- packages/clerk-js/src/core/resources/Session.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-429-unauthenticated.md b/.changeset/fix-429-unauthenticated.md index 264d8f4376d..cf500a5ef1c 100644 --- a/.changeset/fix-429-unauthenticated.md +++ b/.changeset/fix-429-unauthenticated.md @@ -3,4 +3,4 @@ '@clerk/shared': patch --- -Fix 429 (rate limited) responses being incorrectly treated as authentication failures, which caused users to be signed out when the API was rate limiting requests. Rate limited responses now trigger a degraded status and retry instead of the unauthenticated flow. +Narrow the error conditions that trigger the unauthenticated flow (sign-out) to only high-confidence authentication failures (401, 422). Previously, all 4xx errors — including 429 rate limits — were treated as auth failures, which could sign users out during transient rate limiting. Non-auth errors from `setActive` now propagate to the caller instead of being silently swallowed. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 41e6c316eda..63bb0bc2d7d 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -3,7 +3,8 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkOfflineError, ClerkWebAuthnError, - isUnauthenticatedError, + is429Error, + is4xxError, MissingExpiredTokenError, } from '@clerk/shared/error'; import { @@ -151,9 +152,10 @@ export class Session extends BaseResource implements SessionResource { }; getToken: GetToken = async (options?: GetTokenOptions): Promise => { - // This will retry the getToken call if it fails with a non-4xx error - // For offline state, we use shorter retries (~15s total) before throwing ClerkOfflineError - // For other errors, we retry up to 8 times over ~3 minutes + // Retry on transient failures (5xx, network errors, 429 rate limits). + // Do not retry on deterministic client errors (4xx) — they won't resolve on their own. + // For offline state, we use shorter retries (~15s total) before throwing ClerkOfflineError. + // For other errors, we retry up to 8 times over ~3 minutes. try { const result = await retry(() => this._getToken(options), { factor: 1.55, @@ -161,7 +163,7 @@ export class Session extends BaseResource implements SessionResource { maxDelayBetweenRetries: 50 * 1_000, jitter: false, shouldRetry: (error, iterationsCount) => { - if (isUnauthenticatedError(error)) { + if (is4xxError(error) && !is429Error(error)) { return false; } From f580dcf5118bc34697f328364714dfc0e2ab56a2 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 13:44:41 -0600 Subject: [PATCH 07/10] test: Remove non-deterministic 429 signed-in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "remains signed in" test passes even without the fix due to a race condition — handleUnauthenticated is fire-and-forget and may not complete within the wait window. The remaining two tests (setActive surfaces error, no recursive handleUnauthenticated) are deterministic and sufficient. Co-Authored-By: Claude Opus 4.6 --- integration/tests/resiliency.test.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index 510e5536f90..63e81382a5f 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -312,30 +312,6 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc }); test.describe('429 rate limit resiliency', () => { - test('signed-in user remains signed in when /tokens returns 429', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.po.signIn.goTo(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); - - // Intercept token requests to return 429 - await page.route('**/v1/client/sessions/*/tokens**', route => { - return route.fulfill(make429ClerkResponse()); - }); - - // Clear the token cache so the next poller tick makes a fresh network request - await page.evaluate(() => window.Clerk?.session?.clearCache()); - - // Allow time for the poller to fire and handle the 429 - await page.waitForTimeout(3_000); - - await page.unrouteAll(); - - // The user must still be signed in — 429 is not an auth failure - await u.po.expect.toBeSignedIn(); - }); - test('setActive surfaces 429 error to the developer instead of silently swallowing', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); From e47967a952e948b713f6be82aaa0f26b0a3020d5 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 14:04:53 -0600 Subject: [PATCH 08/10] fix(test): Add missing curly braces to satisfy lint rule Co-Authored-By: Claude Opus 4.6 --- integration/tests/resiliency.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index 63e81382a5f..dbb3dab9ceb 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -327,7 +327,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc // setActive should surface the 429 error so the developer can handle it const error = await page.evaluate(async () => { const session = window.Clerk?.session; - if (!session) return null; + if (!session) { + return null; + } try { await window.Clerk?.setActive({ session }); return null; From f6b8b317e8deaaa8aa6602dcdfce0bc334721989 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 14:37:44 -0600 Subject: [PATCH 09/10] fix lint --- packages/shared/src/__tests__/error.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index 82edc255e18..b4d21e8e45d 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -5,8 +5,8 @@ import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, - is429Error, is4xxError, + is429Error, isClerkRuntimeError, isUnauthenticatedError, } from '../error'; From f11e4d40aec88ef65bf518504f43a1f25c2d36c1 Mon Sep 17 00:00:00 2001 From: brkalow Date: Fri, 6 Mar 2026 15:02:25 -0600 Subject: [PATCH 10/10] lint --- packages/clerk-js/src/core/resources/Session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 63bb0bc2d7d..303a4e428fe 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -3,8 +3,8 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; import { ClerkOfflineError, ClerkWebAuthnError, - is429Error, is4xxError, + is429Error, MissingExpiredTokenError, } from '@clerk/shared/error'; import {