Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aec61e8
fix: init retry logic
jacekradko Nov 3, 2025
0d7919b
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 24, 2025
8e58d10
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 26, 2025
00c834d
wip
jacekradko Nov 26, 2025
dbf02e9
guard against invalid maxAttempts
jacekradko Nov 26, 2025
b15a32c
guard against mounting components with missing env
jacekradko Nov 26, 2025
b55bf67
changeset
jacekradko Nov 26, 2025
2a0e348
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 26, 2025
e6b28f3
Merge branch 'main' into fix/init-retry-logic
jacekradko Nov 28, 2025
95e6d64
wip
jacekradko Nov 28, 2025
b739aab
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 1, 2025
2239274
wip
jacekradko Dec 1, 2025
f2bf4a9
wip
jacekradko Dec 1, 2025
ee40019
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 3, 2025
b523532
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 9, 2025
17bcea8
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 9, 2025
c742214
wip
jacekradko Dec 9, 2025
00b626a
wip
jacekradko Dec 9, 2025
2db92aa
Merge branch 'main' into fix/init-retry-logic
jacekradko Dec 10, 2025
645142b
Merge branch 'main' into fix/init-retry-logic
jacekradko Jan 30, 2026
5529cf3
fix(clerk-js): refactor init retry logic with exponential backoff
jacekradko Jan 30, 2026
11b4913
merge main into fix/init-retry-logic
jacekradko Mar 23, 2026
b6f68e2
fix: use correct #clerkUI casing after merge
jacekradko Mar 23, 2026
95c4f2f
Merge branch 'main' into fix/init-retry-logic
jacekradko May 13, 2026
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
126 changes: 125 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,4 @@
import { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { ClerkRuntimeError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import type {
ActiveSessionResource,
PendingSessionResource,
Expand All @@ -13,8 +13,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test,
import { mockJwt } from '@/test/core-fixtures';

import { mockNativeRuntime } from '../../test/utils';
import { AuthCookieService } from '../auth/AuthCookieService';
import type { DevBrowser } from '../auth/devBrowser';
import { Clerk } from '../clerk';
import * as errorsModule from '../errors';
import { eventBus, events } from '../events';
import type { DisplayConfig, Organization } from '../resources/internal';
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';
Expand Down Expand Up @@ -157,6 +159,128 @@ describe('Clerk singleton', () => {
});
});

describe('load retry behavior', () => {
let originalMountComponentRenderer: typeof Clerk.mountComponentRenderer;

const createMockAuthService = () => ({
decorateUrlWithDevBrowserToken: vi.fn((url: URL) => url),
getSessionCookie: vi.fn(() => null),
handleUnauthenticatedDevBrowser: vi.fn(() => Promise.resolve()),
isSignedOut: vi.fn(() => false),
setClientUatCookieForDevelopmentInstances: vi.fn(),
startPollingForToken: vi.fn(),
stopPollingForToken: vi.fn(),
});

const createMockComponentControls = () => {
const componentInstance = {
mountImpersonationFab: vi.fn(),
updateProps: vi.fn(),
};

return {
ensureMounted: vi.fn().mockResolvedValue(componentInstance),
prioritizedOn: vi.fn(),
};
};

beforeEach(() => {
originalMountComponentRenderer = Clerk.mountComponentRenderer;
});

afterEach(() => {
Clerk.mountComponentRenderer = originalMountComponentRenderer;
vi.useRealTimers();
});

it('retries once when dev browser authentication is lost', async () => {
vi.useFakeTimers();

const mockAuthService = createMockAuthService();
const authCreateSpy = vi
.spyOn(AuthCookieService, 'create')
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);

const componentControls = createMockComponentControls();
const devBrowserError = Object.assign(new Error('dev browser unauthenticated'), {
errors: [{ code: 'dev_browser_unauthenticated' }],
status: 401,
});

const mountSpy = vi
.fn<NonNullable<typeof Clerk.mountComponentRenderer>>()
.mockImplementationOnce(() => {
throw devBrowserError;
})
.mockReturnValue(componentControls);

Clerk.mountComponentRenderer = mountSpy;
mockClientFetch.mockClear();

const sut = new Clerk(productionPublishableKey);

try {
const loadPromise = sut.load();

await vi.runAllTimersAsync();
await loadPromise;
} finally {
authCreateSpy.mockRestore();
}

expect(mountSpy).toHaveBeenCalledTimes(2);
expect(mockAuthService.handleUnauthenticatedDevBrowser).toHaveBeenCalledTimes(1);
expect(mockClientFetch).toHaveBeenCalledTimes(2);
});

it('surfaces network errors after exhausting retries', async () => {
vi.useFakeTimers();

const mockAuthService = createMockAuthService();
const authCreateSpy = vi
.spyOn(AuthCookieService, 'create')
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);

const networkError = new ClerkRuntimeError('Network failure', { code: 'network_error' });
const mountSpy = vi.fn<NonNullable<typeof Clerk.mountComponentRenderer>>().mockImplementation(() => {
throw networkError;
});

Clerk.mountComponentRenderer = mountSpy;
mockClientFetch.mockClear();

const errorSpy = vi.spyOn(errorsModule, 'clerkErrorInitFailed');
const sut = new Clerk(productionPublishableKey);

try {
const loadPromise = sut.load();

// Attach rejection handler before advancing timers to avoid unhandled rejection
const expectation = expect(loadPromise).rejects.toThrow(/Something went wrong initializing Clerk/);
await vi.runAllTimersAsync();

const err = await loadPromise.catch(e => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/Something went wrong initializing Clerk/);
const cause = (err as Error).cause as any;
expect(cause).toBeDefined();
expect(cause.code).toBe('network_error');
expect(cause.clerkRuntimeError).toBe(true);

await expectation;

expect(mountSpy).toHaveBeenCalledTimes(2);
expect(mockClientFetch).toHaveBeenCalledTimes(2);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenLastCalledWith(networkError);
expect(mockAuthService.handleUnauthenticatedDevBrowser).not.toHaveBeenCalled();
} finally {
authCreateSpy.mockRestore();
errorSpy.mockRestore();
}
});
});

describe('.setActive', () => {
describe('with `active` session status', () => {
const mockSession = {
Expand Down
176 changes: 87 additions & 89 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage';
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
import { RedirectUrls } from '../utils/redirectUrls';
import { withRetry } from '../utils/retry';
import { AuthCookieService } from './auth/AuthCookieService';
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants';
Expand Down Expand Up @@ -2727,112 +2728,109 @@ export class Clerk implements ClerkInterface {

let initializationDegradedCounter = 0;

let retries = 0;
while (retries < 2) {
retries++;
const initializeClerk = async (): Promise<void> => {
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);

try {
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
}
});
Comment on lines +3064 to +3083
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.

⚠️ Potential issue | 🟡 Minor

Preserve original error when throwing after failed environment fetch.

When the environment fetch fails and no cache exists, line 2745 creates a new ClerkRuntimeError without preserving the original error as the cause. This loses valuable debugging context about what actually failed.

Apply this diff to preserve the original error:

-        .catch(() => {
+        .catch(error => {
           ++initializationDegradedCounter;
           const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
             CLERK_ENVIRONMENT_STORAGE_ENTRY,
             null,
           );

           if (environmentSnapshot) {
             this.updateEnvironment(new Environment(environmentSnapshot));
           } else {
-            throw new ClerkRuntimeError('Failed to fetch environment', { code: 'network_error' });
+            throw new ClerkRuntimeError('Failed to fetch environment', { 
+              code: 'network_error',
+              cause: error,
+            });
           }
         });
📝 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 initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
try {
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(() => {
++initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
} else {
throw new ClerkRuntimeError('Failed to fetch environment', { code: 'network_error' });
}
});
const initEnvironmentPromise = Environment.getInstance()
.fetch({ touch: shouldTouchEnv })
.then(res => this.updateEnvironment(res))
.catch(error => {
+initializationDegradedCounter;
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
CLERK_ENVIRONMENT_STORAGE_ENTRY,
null,
);
if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
} else {
throw new ClerkRuntimeError('Failed to fetch environment', {
code: 'network_error',
cause: error,
});
}
});
🤖 Prompt for AI Agents
In packages/clerk-js/src/core/clerk.ts around lines 2732 to 2747, the catch
handler for the Environment.getInstance().fetch call throws a new
ClerkRuntimeError without preserving the original error; capture the caught
error (e.g. catch(err) { ... }) and when throwing the ClerkRuntimeError include
the original error as the cause (or wrap/attach it in the error payload) so the
original stack/message is preserved for debugging while keeping the existing
degraded-counter increment and fallback-to-cache logic intact.


if (environmentSnapshot) {
this.updateEnvironment(new Environment(environmentSnapshot));
const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res))
.catch(async e => {
/**
* Only handle non 4xx errors, like 5xx errors and network errors.
*/
if (is4xxError(e)) {
throw e;
}
});

const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res))
.catch(async e => {
/**
* Only handle non 4xx errors, like 5xx errors and network errors.
*/
if (is4xxError(e)) {
// bubble it up
throw e;
}

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

// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
return null;
});
};

const initComponents = () => {
if (Clerk.mountComponentRenderer && !this.#componentControls) {
this.#componentControls = Clerk.mountComponentRenderer(
this,
this.environment as Environment,
this.#options,
);
}
};
++initializationDegradedCounter;

const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);
const jwtInCookie = this.#authService?.getSessionCookie();
const localClient = createClientFromJwt(jwtInCookie);

if (clientResult.status === 'rejected') {
const e = clientResult.reason;
this.updateClient(localClient);

if (isError(e, 'requires_captcha')) {
initComponents();
await initClient();
} else {
throw e;
}
}
/**
* 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();

this.#authService?.setClientUatCookieForDevelopmentInstances();
await this.session
?.getToken({ skipCache: true })
.catch(() => null)
.finally(() => {
this.#authService?.startPollingForToken();
});

if (await this.#redirectFAPIInitiatedFlow()) {
return;
return null;
});
};
Comment thread
jacekradko marked this conversation as resolved.

const initComponents = () => {
if (Clerk.mountComponentRenderer && !this.#componentControls) {
this.#componentControls = Clerk.mountComponentRenderer(this, this.environment as Environment, this.#options);
}
};

initComponents();
const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);

break;
} catch (err) {
if (isError(err, 'dev_browser_unauthenticated')) {
await this.#authService.handleUnauthenticatedDevBrowser();
} else if (!isValidBrowserOnline()) {
console.warn(err);
return;
if (clientResult.status === 'rejected') {
const e = clientResult.reason;

if (isError(e, 'requires_captcha')) {
initComponents();
await initClient();
} else {
throw err;
throw e;
}
}
Comment thread
jacekradko marked this conversation as resolved.
Outdated

if (retries >= 2) {
clerkErrorInitFailed();
this.#authService?.setClientUatCookieForDevelopmentInstances();

if (await this.#redirectFAPIInitiatedFlow()) {
return;
}

initComponents();
};

try {
await withRetry(initializeClerk, {
jitter: true,
maxAttempts: 2,
shouldRetry: async error => {
if (!isValidBrowserOnline()) {
console.warn(error);
return false;
}

const isDevBrowserUnauthenticated = isError(error as any, 'dev_browser_unauthenticated');
const isNetworkError = isClerkRuntimeError(error) && error.code === 'network_error';

if (isDevBrowserUnauthenticated && this.#authService) {
await this.#authService.handleUnauthenticatedDevBrowser();
return true;
}

return isNetworkError;
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
clerkErrorInitFailed(err);
}

this.#captchaHeartbeat = new CaptchaHeartbeat(this);
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export function clerkNetworkError(url: string, e: Error): never {
throw new Error(`${errorPrefix} Network error at "${url}" - ${e}. Please try again.`);
}

export function clerkErrorInitFailed(): never {
throw new Error(`${errorPrefix} Something went wrong initializing Clerk.`);
export function clerkErrorInitFailed(error?: unknown): never {
throw new Error(`${errorPrefix} Something went wrong initializing Clerk.`, { cause: error });
}

export function clerkErrorDevInitFailed(msg = ''): never {
Expand Down
Loading
Loading