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, so a cold `Clerk.load()` renders identity from the session cookie in seconds instead of hanging while retries run. Adds a `timeLimit` utility to `@clerk/shared/utils`.
138 changes: 138 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 +821,141 @@ 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 sessionlessClient = () => ({ signedInSessions: [], lastActiveSessionId: null });

// `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 +838 to +848

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'));
mockCreateClientFromJwt.mockReturnValue(sessionlessClient());
});

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();
});

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 = {
id: 'sess_1',
status: 'active',
user: {},
getToken,
clearCache,
};
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));

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

expect(clearCache).toHaveBeenCalledTimes(1);
expect(getToken).toHaveBeenCalledTimes(1);
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 = {
id: 'sess_1',
status: 'active',
user: {},
getToken,
clearCache,
};
mockClientFetch.mockRejectedValue(new Error('client fetch failed'));
mockCreateClientFromJwt.mockReturnValue(sessionClient(session));

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

expect(clearCache).toHaveBeenCalledTimes(2);
expect(callLog).toEqual(['startPoll', 'stopPoll', 'clearCache', 'getToken', 'clearCache', 'startPoll']);
const secondClearOrder = clearCache.mock.invocationCallOrder[1];
const lastStartPollOrder = startPollSpy.mock.invocationCallOrder.at(-1);
expect(secondClearOrder).toBeLessThan(lastStartPollOrder!);
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();
});
});
});

describe('.signOut()', () => {
Expand Down
31 changes: 19 additions & 12 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,7 @@ 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';
const INITIALIZATION_TIMEOUT_MS = 5_000;
const defaultOptions: ClerkOptions = {
polling: true,
standardBrowser: true,
Expand Down Expand Up @@ -3174,8 +3175,7 @@ export class Clerk implements ClerkInterface {
});

const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
return timeLimit(Client.getOrCreateInstance().fetch(), INITIALIZATION_TIMEOUT_MS)
.then(res => this.updateClient(res))
.catch(async e => {
/**
Expand All @@ -3199,16 +3199,23 @@ export class Clerk implements ClerkInterface {
*/
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 session = this.session;
if (session) {
session.clearCache();
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;
})
.finally(() => {
this.#authService?.startPollingForToken();
});
} else {
this.#authService?.startPollingForToken();
}

// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
return null;
});
};
Expand Down
44 changes: 44 additions & 0 deletions packages/shared/src/utils/__tests__/timeLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';

import { timeLimit } from '../timeLimit';

describe('timeLimit', () => {
it('resolves with the value when the promise settles before ms', async () => {
await expect(timeLimit(Promise.resolve('token'), 10_000)).resolves.toBe('token');
});

it('resolves cleanly when the value is a non-promise (e.g. undefined)', async () => {
await expect(timeLimit(undefined, 10_000)).resolves.toBeUndefined();
});

it('rejects with a plain Error (no status) when ms elapses first', async () => {
vi.useFakeTimers();
try {
const neverSettles = new Promise<string>(() => {});
const result = timeLimit(neverSettles, 50).catch(error => error);

await vi.advanceTimersByTimeAsync(50);

const error = await result;
expect(error).toBeInstanceOf(Error);
expect((error as { status?: unknown }).status).toBeUndefined();
} finally {
vi.useRealTimers();
}
});

it('clears the timeout when the value settles first, leaving no pending timer', async () => {
vi.useFakeTimers();
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
try {
await timeLimit(Promise.resolve('fast'), 10_000);

expect(clearSpy).toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(10_000);
} finally {
clearSpy.mockRestore();
vi.useRealTimers();
}
});
});
1 change: 1 addition & 0 deletions packages/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './runtimeEnvironment';
export { handleValueOrFn } from './handleValueOrFn';
export { runIfFunctionOrReturn } from './runIfFunctionOrReturn';
export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge';
export { timeLimit } from './timeLimit';
14 changes: 14 additions & 0 deletions packages/shared/src/utils/timeLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function timeLimit<T>(value: T | PromiseLike<T>, ms: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout>;

const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);

// Let a Node process exit while the timeout is still pending; browsers return a number with no unref.
(timeoutId as { unref?: () => void }).unref?.();
});

return Promise.race([Promise.resolve(value), timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
Loading