Skip to content

Commit aec61e8

Browse files
committed
fix: init retry logic
1 parent f47b5a3 commit aec61e8

File tree

6 files changed

+418
-93
lines changed

6 files changed

+418
-93
lines changed

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
1+
import { ClerkRuntimeError, EmailLinkErrorCodeStatus } from '@clerk/shared/error';
22
import type {
33
ActiveSessionResource,
44
PendingSessionResource,
@@ -13,8 +13,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, test,
1313
import { mockJwt } from '@/test/core-fixtures';
1414

1515
import { mockNativeRuntime } from '../../test/utils';
16+
import { AuthCookieService } from '../auth/AuthCookieService';
1617
import type { DevBrowser } from '../auth/devBrowser';
1718
import { Clerk } from '../clerk';
19+
import * as errorsModule from '../errors';
1820
import { eventBus, events } from '../events';
1921
import type { DisplayConfig, Organization } from '../resources/internal';
2022
import { BaseResource, Client, Environment, SignIn, SignUp } from '../resources/internal';
@@ -157,6 +159,128 @@ describe('Clerk singleton', () => {
157159
});
158160
});
159161

162+
describe('load retry behavior', () => {
163+
let originalMountComponentRenderer: typeof Clerk.mountComponentRenderer;
164+
165+
const createMockAuthService = () => ({
166+
decorateUrlWithDevBrowserToken: vi.fn((url: URL) => url),
167+
getSessionCookie: vi.fn(() => null),
168+
handleUnauthenticatedDevBrowser: vi.fn(() => Promise.resolve()),
169+
isSignedOut: vi.fn(() => false),
170+
setClientUatCookieForDevelopmentInstances: vi.fn(),
171+
startPollingForToken: vi.fn(),
172+
stopPollingForToken: vi.fn(),
173+
});
174+
175+
const createMockComponentControls = () => {
176+
const componentInstance = {
177+
mountImpersonationFab: vi.fn(),
178+
updateProps: vi.fn(),
179+
};
180+
181+
return {
182+
ensureMounted: vi.fn().mockResolvedValue(componentInstance),
183+
prioritizedOn: vi.fn(),
184+
};
185+
};
186+
187+
beforeEach(() => {
188+
originalMountComponentRenderer = Clerk.mountComponentRenderer;
189+
});
190+
191+
afterEach(() => {
192+
Clerk.mountComponentRenderer = originalMountComponentRenderer;
193+
vi.useRealTimers();
194+
});
195+
196+
it('retries once when dev browser authentication is lost', async () => {
197+
vi.useFakeTimers();
198+
199+
const mockAuthService = createMockAuthService();
200+
const authCreateSpy = vi
201+
.spyOn(AuthCookieService, 'create')
202+
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);
203+
204+
const componentControls = createMockComponentControls();
205+
const devBrowserError = Object.assign(new Error('dev browser unauthenticated'), {
206+
errors: [{ code: 'dev_browser_unauthenticated' }],
207+
status: 401,
208+
});
209+
210+
const mountSpy = vi
211+
.fn<NonNullable<typeof Clerk.mountComponentRenderer>>()
212+
.mockImplementationOnce(() => {
213+
throw devBrowserError;
214+
})
215+
.mockReturnValue(componentControls);
216+
217+
Clerk.mountComponentRenderer = mountSpy;
218+
mockClientFetch.mockClear();
219+
220+
const sut = new Clerk(productionPublishableKey);
221+
222+
try {
223+
const loadPromise = sut.load();
224+
225+
await vi.runAllTimersAsync();
226+
await loadPromise;
227+
} finally {
228+
authCreateSpy.mockRestore();
229+
}
230+
231+
expect(mountSpy).toHaveBeenCalledTimes(2);
232+
expect(mockAuthService.handleUnauthenticatedDevBrowser).toHaveBeenCalledTimes(1);
233+
expect(mockClientFetch).toHaveBeenCalledTimes(2);
234+
});
235+
236+
it('surfaces network errors after exhausting retries', async () => {
237+
vi.useFakeTimers();
238+
239+
const mockAuthService = createMockAuthService();
240+
const authCreateSpy = vi
241+
.spyOn(AuthCookieService, 'create')
242+
.mockResolvedValue(mockAuthService as unknown as AuthCookieService);
243+
244+
const networkError = new ClerkRuntimeError('Network failure', { code: 'network_error' });
245+
const mountSpy = vi.fn<NonNullable<typeof Clerk.mountComponentRenderer>>().mockImplementation(() => {
246+
throw networkError;
247+
});
248+
249+
Clerk.mountComponentRenderer = mountSpy;
250+
mockClientFetch.mockClear();
251+
252+
const errorSpy = vi.spyOn(errorsModule, 'clerkErrorInitFailed');
253+
const sut = new Clerk(productionPublishableKey);
254+
255+
try {
256+
const loadPromise = sut.load();
257+
258+
await vi.runAllTimersAsync();
259+
260+
try {
261+
await loadPromise;
262+
throw new Error('Expected load to throw');
263+
} catch (err) {
264+
expect(err).toBeInstanceOf(Error);
265+
expect((err as Error).message).toMatch(/Something went wrong initializing Clerk/);
266+
const cause = (err as Error).cause as any;
267+
expect(cause).toBeDefined();
268+
expect(cause.code).toBe('network_error');
269+
expect(cause.clerkRuntimeError).toBe(true);
270+
}
271+
272+
expect(mountSpy).toHaveBeenCalledTimes(2);
273+
expect(mockClientFetch).toHaveBeenCalledTimes(2);
274+
expect(errorSpy).toHaveBeenCalledTimes(1);
275+
expect(errorSpy).toHaveBeenLastCalledWith(networkError);
276+
expect(mockAuthService.handleUnauthenticatedDevBrowser).not.toHaveBeenCalled();
277+
} finally {
278+
authCreateSpy.mockRestore();
279+
errorSpy.mockRestore();
280+
}
281+
});
282+
});
283+
160284
describe('.setActive', () => {
161285
describe('with `active` session status', () => {
162286
const mockSession = {

packages/clerk-js/src/core/clerk.ts

Lines changed: 87 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
136136
import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage';
137137
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
138138
import { RedirectUrls } from '../utils/redirectUrls';
139+
import { withRetry } from '../utils/retry';
139140
import { AuthCookieService } from './auth/AuthCookieService';
140141
import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat';
141142
import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants';
@@ -2570,112 +2571,109 @@ export class Clerk implements ClerkInterface {
25702571

25712572
let initializationDegradedCounter = 0;
25722573

2573-
let retries = 0;
2574-
while (retries < 2) {
2575-
retries++;
2574+
const initializeClerk = async (): Promise<void> => {
2575+
const initEnvironmentPromise = Environment.getInstance()
2576+
.fetch({ touch: shouldTouchEnv })
2577+
.then(res => this.updateEnvironment(res))
2578+
.catch(() => {
2579+
++initializationDegradedCounter;
2580+
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
2581+
CLERK_ENVIRONMENT_STORAGE_ENTRY,
2582+
null,
2583+
);
25762584

2577-
try {
2578-
const initEnvironmentPromise = Environment.getInstance()
2579-
.fetch({ touch: shouldTouchEnv })
2580-
.then(res => this.updateEnvironment(res))
2581-
.catch(() => {
2582-
++initializationDegradedCounter;
2583-
const environmentSnapshot = SafeLocalStorage.getItem<EnvironmentJSONSnapshot | null>(
2584-
CLERK_ENVIRONMENT_STORAGE_ENTRY,
2585-
null,
2586-
);
2585+
if (environmentSnapshot) {
2586+
this.updateEnvironment(new Environment(environmentSnapshot));
2587+
}
2588+
});
25872589

2588-
if (environmentSnapshot) {
2589-
this.updateEnvironment(new Environment(environmentSnapshot));
2590+
const initClient = async () => {
2591+
return Client.getOrCreateInstance()
2592+
.fetch()
2593+
.then(res => this.updateClient(res))
2594+
.catch(async e => {
2595+
/**
2596+
* Only handle non 4xx errors, like 5xx errors and network errors.
2597+
*/
2598+
if (is4xxError(e)) {
2599+
throw e;
25902600
}
2591-
});
25922601

2593-
const initClient = async () => {
2594-
return Client.getOrCreateInstance()
2595-
.fetch()
2596-
.then(res => this.updateClient(res))
2597-
.catch(async e => {
2598-
/**
2599-
* Only handle non 4xx errors, like 5xx errors and network errors.
2600-
*/
2601-
if (is4xxError(e)) {
2602-
// bubble it up
2603-
throw e;
2604-
}
2605-
2606-
++initializationDegradedCounter;
2607-
2608-
const jwtInCookie = this.#authService?.getSessionCookie();
2609-
const localClient = createClientFromJwt(jwtInCookie);
2610-
2611-
this.updateClient(localClient);
2612-
2613-
/**
2614-
* In most scenarios we want the poller to stop while we are fetching a fresh token during an outage.
2615-
* We want to avoid having the below `getToken()` retrying at the same time as the poller.
2616-
*/
2617-
this.#authService?.stopPollingForToken();
2618-
2619-
// Attempt to grab a fresh token
2620-
await this.session
2621-
?.getToken({ skipCache: true })
2622-
// If the token fetch fails, let Clerk be marked as loaded and leave it up to the poller.
2623-
.catch(() => null)
2624-
.finally(() => {
2625-
this.#authService?.startPollingForToken();
2626-
});
2627-
2628-
// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
2629-
return null;
2630-
});
2631-
};
2632-
2633-
const initComponents = () => {
2634-
if (Clerk.mountComponentRenderer && !this.#componentControls) {
2635-
this.#componentControls = Clerk.mountComponentRenderer(
2636-
this,
2637-
this.environment as Environment,
2638-
this.#options,
2639-
);
2640-
}
2641-
};
2602+
++initializationDegradedCounter;
26422603

2643-
const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);
2604+
const jwtInCookie = this.#authService?.getSessionCookie();
2605+
const localClient = createClientFromJwt(jwtInCookie);
26442606

2645-
if (clientResult.status === 'rejected') {
2646-
const e = clientResult.reason;
2607+
this.updateClient(localClient);
26472608

2648-
if (isError(e, 'requires_captcha')) {
2649-
initComponents();
2650-
await initClient();
2651-
} else {
2652-
throw e;
2653-
}
2654-
}
2609+
/**
2610+
* In most scenarios we want the poller to stop while we are fetching a fresh token during an outage.
2611+
* We want to avoid having the below `getToken()` retrying at the same time as the poller.
2612+
*/
2613+
this.#authService?.stopPollingForToken();
26552614

2656-
this.#authService?.setClientUatCookieForDevelopmentInstances();
2615+
await this.session
2616+
?.getToken({ skipCache: true })
2617+
.catch(() => null)
2618+
.finally(() => {
2619+
this.#authService?.startPollingForToken();
2620+
});
26572621

2658-
if (await this.#redirectFAPIInitiatedFlow()) {
2659-
return;
2622+
return null;
2623+
});
2624+
};
2625+
2626+
const initComponents = () => {
2627+
if (Clerk.mountComponentRenderer && !this.#componentControls) {
2628+
this.#componentControls = Clerk.mountComponentRenderer(this, this.environment as Environment, this.#options);
26602629
}
2630+
};
26612631

2662-
initComponents();
2632+
const [, clientResult] = await allSettled([initEnvironmentPromise, initClient()]);
26632633

2664-
break;
2665-
} catch (err) {
2666-
if (isError(err, 'dev_browser_unauthenticated')) {
2667-
await this.#authService.handleUnauthenticatedDevBrowser();
2668-
} else if (!isValidBrowserOnline()) {
2669-
console.warn(err);
2670-
return;
2634+
if (clientResult.status === 'rejected') {
2635+
const e = clientResult.reason;
2636+
2637+
if (isError(e, 'requires_captcha')) {
2638+
initComponents();
2639+
await initClient();
26712640
} else {
2672-
throw err;
2641+
throw e;
26732642
}
26742643
}
26752644

2676-
if (retries >= 2) {
2677-
clerkErrorInitFailed();
2645+
this.#authService?.setClientUatCookieForDevelopmentInstances();
2646+
2647+
if (await this.#redirectFAPIInitiatedFlow()) {
2648+
return;
26782649
}
2650+
2651+
initComponents();
2652+
};
2653+
2654+
try {
2655+
await withRetry(initializeClerk, {
2656+
jitter: true,
2657+
maxAttempts: 2,
2658+
shouldRetry: async error => {
2659+
if (!isValidBrowserOnline()) {
2660+
console.warn(error);
2661+
return false;
2662+
}
2663+
2664+
const isDevBrowserUnauthenticated = isError(error as any, 'dev_browser_unauthenticated');
2665+
const isNetworkError = isClerkRuntimeError(error) && error.code === 'network_error';
2666+
2667+
if (isDevBrowserUnauthenticated && this.#authService) {
2668+
await this.#authService.handleUnauthenticatedDevBrowser();
2669+
return true;
2670+
}
2671+
2672+
return isNetworkError;
2673+
},
2674+
});
2675+
} catch (err) {
2676+
clerkErrorInitFailed(err);
26792677
}
26802678

26812679
this.#captchaHeartbeat = new CaptchaHeartbeat(this);

packages/clerk-js/src/core/errors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ export function clerkNetworkError(url: string, e: Error): never {
1616
throw new Error(`${errorPrefix} Network error at "${url}" - ${e}. Please try again.`);
1717
}
1818

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

2323
export function clerkErrorDevInitFailed(msg = ''): never {

0 commit comments

Comments
 (0)