Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/clerkjs-fail-fast-slow-origin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Fail fast when the Clerk Frontend API (FAPI) is slow or unreachable during load. The client request and the load-recovery token mint are now bounded by a timeout, and the timed-out client request is aborted instead of being left in flight. A cold `Clerk.load()` renders identity from a freshly minted session token (falling back to the session cookie if the mint fails) in seconds instead of hanging while retries run. After a degraded load, the client is re-fetched in the background without a time limit, so a slow-but-healthy origin recovers full client data (user profile, other sessions) without a reload. Also fixes hooks like `useUser()` keeping the cookie-derived stub user after full user data arrives. Adds a `timeLimit` utility to `@clerk/shared/utils` that optionally aborts an `AbortController` on timeout.
193 changes: 193 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ClerkOfflineError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { createDeferredPromise } from '@clerk/shared/utils';
import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants';
import type {
ActiveSessionResource,
Expand Down Expand Up @@ -26,6 +27,9 @@ const mockEnvironmentFetch = vi.fn(() => Promise.resolve({}));
vi.mock('../resources/Client');
vi.mock('../resources/Environment');

const { mockCreateClientFromJwt } = vi.hoisted(() => ({ mockCreateClientFromJwt: vi.fn() }));
vi.mock('../jwt-client', () => ({ createClientFromJwt: mockCreateClientFromJwt }));

vi.mock('../auth/devBrowser', () => ({
createDevBrowser: (): DevBrowser => ({
clear: vi.fn(),
Expand Down Expand Up @@ -818,6 +822,195 @@ describe('Clerk singleton', () => {
});
},
);

describe('when the client fetch fails or hangs at load', () => {
let startPollSpy: ReturnType<typeof vi.spyOn>;
let stopPollSpy: ReturnType<typeof vi.spyOn>;
let callLog: string[];

const sessionClient = (session: any) => ({
signedInSessions: [session],
lastActiveSessionId: session.id,
});
const makeSession = (overrides: Record<string, unknown> = {}) => ({
id: 'sess_1',
status: 'active',
user: {},
getToken: vi.fn(() => Promise.resolve('fresh-token')),
clearCache: vi.fn(),
...overrides,
});

// `load()` awaits native crypto (cookie suffix) before scheduling the timeout, so a single
// advance can run before the timer exists; advance the budget repeatedly until load settles.
const pumpUntilSettled = async (promise: Promise<unknown>) => {
let settled = false;
const tracked = promise.then(
v => ((settled = true), v),
e => ((settled = true), Promise.reject(e)),
);
for (let i = 0; i < 20 && !settled; i++) {
await vi.advanceTimersByTimeAsync(5000);
}
await tracked;
};
Comment on lines +846 to +856

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make pumpUntilSettled fail instead of hanging.

If load() never settles, the loop exits after 20 advances and the final await tracked still waits forever under fake timers. Throw once the budget is exhausted so CI gets a deterministic failure instead of a stuck test.

Suggested fail-fast guard
 const pumpUntilSettled = async (promise: Promise<unknown>) => {
   let settled = false;
   const tracked = promise.then(
     v => ((settled = true), v),
     e => ((settled = true), Promise.reject(e)),
   );
   for (let i = 0; i < 20 && !settled; i++) {
     await vi.advanceTimersByTimeAsync(5000);
   }
+  if (!settled) {
+    throw new Error('Timed out waiting for Clerk.load() to settle in test');
+  }
   await tracked;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pumpUntilSettled = async (promise: Promise<unknown>) => {
let settled = false;
const tracked = promise.then(
v => ((settled = true), v),
e => ((settled = true), Promise.reject(e)),
);
for (let i = 0; i < 20 && !settled; i++) {
await vi.advanceTimersByTimeAsync(5000);
}
await tracked;
};
const pumpUntilSettled = async (promise: Promise<unknown>) => {
let settled = false;
const tracked = promise.then(
v => ((settled = true), v),
e => ((settled = true), Promise.reject(e)),
);
for (let i = 0; i < 20 && !settled; i++) {
await vi.advanceTimersByTimeAsync(5000);
}
if (!settled) {
throw new Error('Timed out waiting for Clerk.load() to settle in test');
}
await tracked;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/__tests__/clerk.test.ts` around lines 838 - 848,
The helper `pumpUntilSettled` in `clerk.test.ts` can still hang because it
always awaits `tracked` even after the timer budget is exhausted. Update
`pumpUntilSettled` so that if the `load()` promise has not settled after the 20
`vi.advanceTimersByTimeAsync(5000)` iterations, it throws a deterministic error
instead of awaiting forever; keep the existing settled tracking logic and only
await `tracked` when the promise has already resolved or rejected.


beforeEach(async () => {
callLog = [];
// Import at runtime (not top-level) so this module does not reorder the
// static graph and break the auto-mocked Environment/Client resources.
const { AuthCookieService } = await import('../auth/AuthCookieService');
startPollSpy = vi
.spyOn(AuthCookieService.prototype, 'startPollingForToken')
.mockImplementation(() => void callLog.push('startPoll'));
stopPollSpy = vi
.spyOn(AuthCookieService.prototype, 'stopPollingForToken')
.mockImplementation(() => void callLog.push('stopPoll'));
mockClientFetch.mockClear();
mockCreateClientFromJwt.mockReturnValue({ signedInSessions: [], lastActiveSessionId: null });
});

afterEach(() => {
startPollSpy.mockRestore();
stopPollSpy.mockRestore();
mockCreateClientFromJwt.mockReset();
vi.useRealTimers();
});

it('fails fast and marks Clerk degraded when the client fetch hangs', async () => {
vi.useFakeTimers();
mockClientFetch.mockReturnValue(new Promise(() => {}));

const sut = new Clerk(productionPublishableKey);
await pumpUntilSettled(sut.load());

expect(sut.status).toBe('degraded');
expect(stopPollSpy).toHaveBeenCalled();
expect(startPollSpy).toHaveBeenCalled();
// The timed-out /client request gets aborted instead of being left in flight.
expect(mockClientFetch.mock.calls[0]?.[0]?.signal?.aborted).toBe(true);
});

it('builds the degraded identity from the minted token and falls back to the cookie only if the mint fails', async () => {
const stubSession = makeSession();
const freshSession = { id: 'sess_1', status: 'active', user: { id: 'user_fresh' } };
const freshClient = { signedInSessions: [freshSession], lastActiveSessionId: 'sess_1' };
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
mockCreateClientFromJwt.mockReturnValueOnce(sessionClient(stubSession)).mockReturnValueOnce(freshClient);

const sut = new Clerk(productionPublishableKey);
await sut.load();

expect(mockCreateClientFromJwt).toHaveBeenCalledTimes(2);
expect(mockCreateClientFromJwt).toHaveBeenNthCalledWith(2, 'fresh-token');
expect(sut.client).toBe(freshClient);
expect(sut.session).toBe(freshSession);
expect(sut.status).toBe('degraded');
});

it('clears the token cache and mints without skipCache when the client fetch fails with a session present', async () => {
const getToken = vi.fn(() => {
callLog.push('getToken');
return Promise.resolve('fresh-token');
});
const clearCache = vi.fn(() => void callLog.push('clearCache'));
const session = makeSession({ getToken, clearCache });
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));

const sut = new Clerk(productionPublishableKey);
await sut.load();

expect(getToken).toHaveBeenCalledWith();
expect(getToken).not.toHaveBeenCalledWith({ skipCache: true });
expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'startPoll']);
expect(sut.status).toBe('degraded');
});

it('re-clears the token cache before restarting the poller when the recovery mint hangs', async () => {
vi.useFakeTimers();
const getToken = vi.fn(() => {
callLog.push('getToken');
return new Promise<string>(() => {});
});
const clearCache = vi.fn(() => void callLog.push('clearCache'));
const session = makeSession({ getToken, clearCache });
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));

const sut = new Clerk(productionPublishableKey);
await pumpUntilSettled(sut.load());

expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'clearCache', 'startPoll']);
expect(sut.status).toBe('degraded');
});

it('renders the empty client without minting when there is no session cookie', async () => {
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));

const sut = new Clerk(productionPublishableKey);
await sut.load();

expect(sut.session).toBeNull();
expect(callLog).toEqual(['startPoll', 'stopPoll', 'startPoll']);
expect(sut.status).toBe('degraded');
});

it('rethrows a 4xx client error without entering the mint path', async () => {
const err = Object.assign(new Error('bad request'), { status: 400 });
mockClientFetch.mockRejectedValue(err);

const sut = new Clerk(productionPublishableKey);
await expect(sut.load()).rejects.toBe(err);

expect(mockCreateClientFromJwt).not.toHaveBeenCalled();
});

it('re-fetches /client in the background after a degraded load and applies the late response', async () => {
vi.useFakeTimers();
const stubSession = makeSession();
const realSession = { id: 'sess_1', status: 'active', user: { id: 'user_real' } };
const realClient = { id: 'client_real', signedInSessions: [realSession], lastActiveSessionId: 'sess_1' };
const refetch = createDeferredPromise();
mockClientFetch.mockRejectedValueOnce(new Error('client fetch failed')).mockReturnValueOnce(refetch.promise);
mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession));

const sut = new Clerk(productionPublishableKey);
await pumpUntilSettled(sut.load());

expect(sut.status).toBe('degraded');
expect(mockClientFetch).toHaveBeenCalledTimes(2);

// The background retry is not bounded by INITIALIZATION_TIMEOUT_MS.
await vi.advanceTimersByTimeAsync(60_000);
refetch.resolve(realClient);
await vi.advanceTimersByTimeAsync(0);

expect(sut.client).toBe(realClient);
expect(sut.session).toBe(realSession);
});

it('discards the background /client response when something else updated the client while it was in flight', async () => {
const stubSession = makeSession();
const refetch = createDeferredPromise();
mockClientFetch.mockRejectedValueOnce(new Error('client fetch failed')).mockReturnValueOnce(refetch.promise);
mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession));

const sut = new Clerk(productionPublishableKey);
await sut.load();

// Something newer lands while the background retry is still in flight, e.g. a sign-out
// or a mutation's piggybacked client.
const interimClient = { id: 'client_interim', signedInSessions: [], lastActiveSessionId: null };
sut.updateClient(interimClient as any);

const staleClient = { id: 'client_stale', signedInSessions: [stubSession], lastActiveSessionId: 'sess_1' };
refetch.resolve(staleClient);
await new Promise(resolve => setTimeout(resolve, 0));

expect(sut.client).toBe(interimClient);
});
});
});

describe('updateSessionCookie monotonic backstop', () => {
Expand Down
28 changes: 28 additions & 0 deletions packages/clerk-js/src/core/__tests__/jwt-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';

import { mockJwt } from '@/test/core-fixtures';

import { createClientFromJwt } from '../jwt-client';

describe('createClientFromJwt', () => {
it('creates a client with a session and user derived from the JWT claims', () => {
const client = createClientFromJwt(mockJwt);

expect(client.lastActiveSessionId).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9');
expect(client.signedInSessions[0]?.id).toBe('sess_2GbDB4enNdCa5vS1zpC3Xzg9tK9');
expect(client.signedInSessions[0]?.user?.id).toBe('user_2GIpXOEpVyJw51rkZn9Kmnc6Sxr');
});

it('stamps the stub user with an ancient updatedAt so the real user always replaces it in memoized listeners', () => {
const client = createClientFromJwt(mockJwt);

expect(client.signedInSessions[0]?.user?.updatedAt?.getTime()).toBe(1);
});

it('returns an empty client when the JWT is missing', () => {
const client = createClientFromJwt(undefined);

expect(client.signedInSessions).toEqual([]);
expect(client.lastActiveSessionId).toBeNull();
});
});
65 changes: 49 additions & 16 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ import type {
} from '@clerk/shared/types';
import type { ClerkUI } from '@clerk/shared/ui';
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils';
import { allSettled, handleValueOrFn, noop, timeLimit } from '@clerk/shared/utils';
import type { QueryClient } from '@tanstack/query-core';

import { debugLogger, initDebugLogger } from '@/utils/debug';
Expand Down Expand Up @@ -215,6 +215,9 @@ const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_o
const CANNOT_RENDER_SELF_SERVE_SSO_DISABLED_ERROR_CODE = 'cannot_render_self_serve_sso_disabled';
const CANNOT_RENDER_CONFIGURE_SSO_EMAIL_ADDRESS_DISABLED_ERROR_CODE =
'cannot_render_configure_sso_email_address_disabled';
// Bounds a single origin request at load; a slow response gets no fapiClient retry,
// so this needs to sit above real-world /client latency including cold-mobile DNS+TLS.
const INITIALIZATION_TIMEOUT_MS = 7_000;
const defaultOptions: ClerkOptions = {
polling: true,
standardBrowser: true,
Expand Down Expand Up @@ -267,6 +270,7 @@ export class Clerk implements ClerkInterface {
#fapiClient: FapiClient;
#instanceType?: InstanceType;
#status: ClerkInterface['status'] = 'loading';
#clientUpdateGeneration = 0;
#listeners: Array<(emission: Resources) => void> = [];
#navigationListeners: Array<() => void> = [];
#options: ClerkOptions = {};
Expand Down Expand Up @@ -2905,6 +2909,7 @@ export class Clerk implements ClerkInterface {
// and emitting, library consumers that both read state directly and set up listeners
// could end up in a inconsistent state.
updateClient = (newClient: ClientResource, options?: { __internal_dangerouslySkipEmit?: boolean }): void => {
this.#clientUpdateGeneration++;
if (!this.client) {
// This is the first time client is being
// set, so we also need to set session
Expand Down Expand Up @@ -3174,8 +3179,14 @@ export class Clerk implements ClerkInterface {
});

const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
// Abort the /client request on timeout so it stops running instead of settling on a
// detached instance later; the background retry below owns recovery from then on.
const clientFetchController = new AbortController();
return timeLimit(
Client.getOrCreateInstance().fetch({ signal: clientFetchController.signal }),
INITIALIZATION_TIMEOUT_MS,
clientFetchController,
)
.then(res => this.updateClient(res))
.catch(async e => {
/**
Expand All @@ -3188,27 +3199,49 @@ export class Clerk implements ClerkInterface {

++initializationDegradedCounter;

const jwtInCookie = this.#authService?.getSessionCookie();
const localClient = createClientFromJwt(jwtInCookie);

this.updateClient(localClient);

/**
* In most scenarios we want the poller to stop while we are fetching a fresh token during an outage.
* We want to avoid having the below `getToken()` retrying at the same time as the poller.
*/
this.#authService?.stopPollingForToken();

// Attempt to grab a fresh token
await this.session
?.getToken({ skipCache: true })
// If the token fetch fails, let Clerk be marked as loaded and leave it up to the poller.
.catch(() => null)
.finally(() => {
this.#authService?.startPollingForToken();
const jwtInCookie = this.#authService?.getSessionCookie();
const localClient = createClientFromJwt(jwtInCookie);
const session = this.#defaultSession(localClient);

if (session) {
// Prefer minting a fresh token for the degraded identity: the minter can serve it
// during an origin outage and its claims are fresher than the cookie's. Fall back
// to the cookie identity only when the mint fails or times out.
session.clearCache();
const freshJwt = await timeLimit(session.getToken(), INITIALIZATION_TIMEOUT_MS).catch(() => {
// On timeout the recovery getToken is still in flight with a pending resolver in the cache;
// clear it so the poller's next getToken starts fresh instead of awaiting the abandoned one.
session.clearCache();
return null;
});
this.updateClient(freshJwt ? createClientFromJwt(freshJwt) : localClient);
} else {
this.updateClient(localClient);
}
this.#authService?.startPollingForToken();

// Retry /client in the background through the normal fetch flow (network-level
// retries, no time limit) now that load no longer blocks on it. The response is
// applied only if nothing else updated the client while it was in flight; anything
// newer (a sign-out, a mutation's piggybacked client) must win over the late response.
// A 5xx failure is not retried (fapiClient only retries network errors); in that
// case the client heals via the piggybacked client of the next mutation.
const clientGenerationAtDispatch = this.#clientUpdateGeneration;
void Client.getOrCreateInstance()
.fetch()
.then(res => {
if (this.#clientUpdateGeneration === clientGenerationAtDispatch) {
this.updateClient(res);
}
})
.catch(noop);

// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
return null;
});
};
Expand Down
5 changes: 3 additions & 2 deletions packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,9 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
initialDelay: 700,
maxDelayBetweenRetries: 5000,
shouldRetry: (_: unknown, iterations: number) => {
// We want to retry only GET requests, as other methods are not idempotent.
return overwrittenRequestMethod === 'GET' && iterations < maxTries;
// We want to retry only GET requests, as other methods are not idempotent,
// and stop as soon as the caller aborted the request.
return overwrittenRequestMethod === 'GET' && iterations < maxTries && !fetchOpts.signal?.aborted;
},
onBeforeRetry: (iteration: number): void => {
// Add the retry attempt to the query string params.
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/jwt-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export function createClientFromJwt(jwt: string | undefined | null): Client {
user: {
object: 'user',
id: userId,
// Epoch 1, not 0: unixEpochToDate treats 0 as "now", and a "now" timestamp makes
// memoizeListenerCallback consider this stub newer than the real user fetched later,
// so listeners would keep rendering the stub after the full client arrives.
updated_at: 1,
organization_memberships:
orgId && orgSlug && orgRole
? [
Expand Down
Loading
Loading