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.
206 changes: 205 additions & 1 deletion 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 All @@ -21,11 +22,16 @@ import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';

const mockClientFetch = vi.fn();
// Static raw-JSON fetch used by the background /client retry after a degraded load.
const mockClientStaticFetch = vi.fn();
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 All @@ -38,8 +44,9 @@ vi.mock('../auth/devBrowser', () => ({
}));

Client.getOrCreateInstance = vi.fn().mockImplementation(() => {
return { fetch: mockClientFetch };
return { fetch: mockClientFetch, fromJSON: (data: any) => data };
});
Client._fetch = mockClientStaticFetch as any;
Environment.getInstance = vi.fn().mockImplementation(() => {
return { fetch: mockEnvironmentFetch };
});
Expand Down Expand Up @@ -818,6 +825,203 @@ 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 +849 to +859

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();
// Client._fetch must return a promise; default to a no-op null so degraded paths that don't
// assert on the background retry don't hit undefined.then. The two background tests override this.
mockClientStaticFetch.mockReset();
mockClientStaticFetch.mockResolvedValue(null);
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]?.abortSignal?.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'));
mockClientStaticFetch.mockReturnValueOnce(refetch.promise);
mockCreateClientFromJwt.mockReturnValue(sessionClient(stubSession));

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

expect(sut.status).toBe('degraded');
// The primary /client fetch is Client.fetch(); the background retry is the raw Client._fetch().
expect(mockClientFetch).toHaveBeenCalledTimes(1);
expect(mockClientStaticFetch).toHaveBeenCalledTimes(1);

// The background retry is not bounded by INITIALIZATION_TIMEOUT_MS.
await vi.advanceTimersByTimeAsync(60_000);
refetch.resolve({ response: 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'));
mockClientStaticFetch.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({ response: 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();
});
});
Loading
Loading