Skip to content

Commit

Permalink
fix: Multiple "concurrent" calls to init causing multiple fetches. (#…
Browse files Browse the repository at this point in the history
…145)

* fix: in-memory lock to only init once at a time per SDK key
* bump version to v3.9.3-alpha.0
  • Loading branch information
typotter authored Jan 13, 2025
1 parent b55bfe2 commit 0e6b0d4
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 1 deletion.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk",
"version": "3.9.2-alpha.0",
"version": "3.9.3-alpha.0",
"description": "Eppo SDK for client-side JavaScript applications",
"main": "dist/index.js",
"files": [
Expand Down
98 changes: 98 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,104 @@ describe('initialization options', () => {
expect(callCount).toBe(2);
});

it('only fetches/does initialization workload once if init is called multiple times concurrently', async () => {
let callCount = 0;

global.fetch = jest.fn(() => {
++callCount;
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockConfigResponse),
});
}) as jest.Mock;

const inits: Promise<EppoClient>[] = [];
[...Array(10).keys()].forEach(() => {
inits.push(
init({
apiKey,
baseUrl,
assignmentLogger: mockLogger,
}),
);
});

// Advance timers mid-init to allow retrying
await jest.advanceTimersByTimeAsync(maxRetryDelay);

// Await for all the initialization calls to resolve
const client = await Promise.race(inits);
await Promise.all(inits);

expect(callCount).toBe(1);
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
});

it('only fetches/does initialization workload once per API key if init is called multiple times concurrently', async () => {
let callCount = 0;

global.fetch = jest.fn(() => {
++callCount;
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockConfigResponse),
});
}) as jest.Mock;

const inits: Promise<EppoClient>[] = [];
[
'KEY_1',
'KEY_2',
'KEY_1',
'KEY_2',
'KEY_1',
'KEY_2',
'KEY_3',
'KEY_1',
'KEY_2',
'KEY_3',
].forEach((varyingAPIKey) => {
inits.push(
init({
apiKey: varyingAPIKey,
baseUrl,
forceReinitialize: true,
assignmentLogger: mockLogger,
}),
);
});

// Advance timers mid-init to allow retrying
await jest.advanceTimersByTimeAsync(maxRetryDelay);

// Await for all the initialization calls to resolve
const client = await Promise.race(inits);
await Promise.all(inits);

expect(callCount).toBe(3);
callCount = 0;
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');

const reInits: Promise<EppoClient>[] = [];
['KEY_1', 'KEY_2', 'KEY_3', 'KEY_4'].forEach((varyingAPIKey) => {
reInits.push(
init({
apiKey: varyingAPIKey,
forceReinitialize: true,
baseUrl,
assignmentLogger: mockLogger,
}),
);
});

await Promise.all(reInits);

expect(callCount).toBe(4);
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
});

it('do not reinitialize if already initialized', async () => {
let callCount = 0;

Expand Down
25 changes: 25 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ export function offlineInit(config: IClientConfigSync): EppoClient {
return EppoJSClient.instance;
}

type SDKKey = string;

/**
* Tracks initialization by API key. After an initialization completes, the value is removed from the map.
*/
const initializationPromises: Map<SDKKey, Promise<EppoClient>> = new Map();

/**
* Initializes the Eppo client with configuration parameters.
* This method should be called once on application startup.
Expand All @@ -323,6 +330,24 @@ export function offlineInit(config: IClientConfigSync): EppoClient {
*/
export async function init(config: IClientConfig): Promise<EppoClient> {
validation.validateNotBlank(config.apiKey, 'API key required');

// If there is already an init in progress for this apiKey, return that.
let initPromise = initializationPromises.get(config.apiKey);
if (initPromise) {
return initPromise;
}

initPromise = explicitInit(config);

initializationPromises.set(config.apiKey, initPromise);

const client = await initPromise;
initializationPromises.delete(config.apiKey);
return client;
}

async function explicitInit(config: IClientConfig): Promise<EppoClient> {
validation.validateNotBlank(config.apiKey, 'API key required');
let initializationError: Error | undefined;
const instance = EppoJSClient.instance;
const {
Expand Down

0 comments on commit 0e6b0d4

Please sign in to comment.