Skip to content

Commit 0e6b0d4

Browse files
authored
fix: Multiple "concurrent" calls to init causing multiple fetches. (#145)
* fix: in-memory lock to only init once at a time per SDK key * bump version to v3.9.3-alpha.0
1 parent b55bfe2 commit 0e6b0d4

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk",
3-
"version": "3.9.2-alpha.0",
3+
"version": "3.9.3-alpha.0",
44
"description": "Eppo SDK for client-side JavaScript applications",
55
"main": "dist/index.js",
66
"files": [

src/index.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,104 @@ describe('initialization options', () => {
503503
expect(callCount).toBe(2);
504504
});
505505

506+
it('only fetches/does initialization workload once if init is called multiple times concurrently', async () => {
507+
let callCount = 0;
508+
509+
global.fetch = jest.fn(() => {
510+
++callCount;
511+
return Promise.resolve({
512+
ok: true,
513+
status: 200,
514+
json: () => Promise.resolve(mockConfigResponse),
515+
});
516+
}) as jest.Mock;
517+
518+
const inits: Promise<EppoClient>[] = [];
519+
[...Array(10).keys()].forEach(() => {
520+
inits.push(
521+
init({
522+
apiKey,
523+
baseUrl,
524+
assignmentLogger: mockLogger,
525+
}),
526+
);
527+
});
528+
529+
// Advance timers mid-init to allow retrying
530+
await jest.advanceTimersByTimeAsync(maxRetryDelay);
531+
532+
// Await for all the initialization calls to resolve
533+
const client = await Promise.race(inits);
534+
await Promise.all(inits);
535+
536+
expect(callCount).toBe(1);
537+
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
538+
});
539+
540+
it('only fetches/does initialization workload once per API key if init is called multiple times concurrently', async () => {
541+
let callCount = 0;
542+
543+
global.fetch = jest.fn(() => {
544+
++callCount;
545+
return Promise.resolve({
546+
ok: true,
547+
status: 200,
548+
json: () => Promise.resolve(mockConfigResponse),
549+
});
550+
}) as jest.Mock;
551+
552+
const inits: Promise<EppoClient>[] = [];
553+
[
554+
'KEY_1',
555+
'KEY_2',
556+
'KEY_1',
557+
'KEY_2',
558+
'KEY_1',
559+
'KEY_2',
560+
'KEY_3',
561+
'KEY_1',
562+
'KEY_2',
563+
'KEY_3',
564+
].forEach((varyingAPIKey) => {
565+
inits.push(
566+
init({
567+
apiKey: varyingAPIKey,
568+
baseUrl,
569+
forceReinitialize: true,
570+
assignmentLogger: mockLogger,
571+
}),
572+
);
573+
});
574+
575+
// Advance timers mid-init to allow retrying
576+
await jest.advanceTimersByTimeAsync(maxRetryDelay);
577+
578+
// Await for all the initialization calls to resolve
579+
const client = await Promise.race(inits);
580+
await Promise.all(inits);
581+
582+
expect(callCount).toBe(3);
583+
callCount = 0;
584+
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
585+
586+
const reInits: Promise<EppoClient>[] = [];
587+
['KEY_1', 'KEY_2', 'KEY_3', 'KEY_4'].forEach((varyingAPIKey) => {
588+
reInits.push(
589+
init({
590+
apiKey: varyingAPIKey,
591+
forceReinitialize: true,
592+
baseUrl,
593+
assignmentLogger: mockLogger,
594+
}),
595+
);
596+
});
597+
598+
await Promise.all(reInits);
599+
600+
expect(callCount).toBe(4);
601+
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
602+
});
603+
506604
it('do not reinitialize if already initialized', async () => {
507605
let callCount = 0;
508606

src/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,13 @@ export function offlineInit(config: IClientConfigSync): EppoClient {
315315
return EppoJSClient.instance;
316316
}
317317

318+
type SDKKey = string;
319+
320+
/**
321+
* Tracks initialization by API key. After an initialization completes, the value is removed from the map.
322+
*/
323+
const initializationPromises: Map<SDKKey, Promise<EppoClient>> = new Map();
324+
318325
/**
319326
* Initializes the Eppo client with configuration parameters.
320327
* This method should be called once on application startup.
@@ -323,6 +330,24 @@ export function offlineInit(config: IClientConfigSync): EppoClient {
323330
*/
324331
export async function init(config: IClientConfig): Promise<EppoClient> {
325332
validation.validateNotBlank(config.apiKey, 'API key required');
333+
334+
// If there is already an init in progress for this apiKey, return that.
335+
let initPromise = initializationPromises.get(config.apiKey);
336+
if (initPromise) {
337+
return initPromise;
338+
}
339+
340+
initPromise = explicitInit(config);
341+
342+
initializationPromises.set(config.apiKey, initPromise);
343+
344+
const client = await initPromise;
345+
initializationPromises.delete(config.apiKey);
346+
return client;
347+
}
348+
349+
async function explicitInit(config: IClientConfig): Promise<EppoClient> {
350+
validation.validateNotBlank(config.apiKey, 'API key required');
326351
let initializationError: Error | undefined;
327352
const instance = EppoJSClient.instance;
328353
const {

0 commit comments

Comments
 (0)