Skip to content

Commit 702a572

Browse files
authored
fix: solve race condition resulting in empty, uninitialized configuration store. (#158)
* fix: don't resolve config load race when fetch returns early with no result * chore: more explicit config loading data types and variables * tests * improving tests * refine tests * cleanup * v3.9.7
1 parent 437b73b commit 702a572

File tree

4 files changed

+212
-44
lines changed

4 files changed

+212
-44
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.6",
3+
"version": "3.9.7",
44
"description": "Eppo SDK for client-side JavaScript applications",
55
"main": "dist/index.js",
66
"files": [

src/config-loader.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Enumerate the kinds of results we can get from a configuration loading source.
3+
*/
4+
export enum ConfigLoaderStatus {
5+
FAILED, // An error was encountered
6+
DID_NOT_PRODUCE, // There was no error encountered, but other logic resulted in no useful configuration from this source
7+
COMPLETED, // Useful configuration was successfully loaded into the configuration store from this source.
8+
}
9+
10+
export enum ConfigSource {
11+
CONFIG_STORE,
12+
FETCH,
13+
NONE,
14+
}
15+
16+
export type ConfigLoadResult = {
17+
source: ConfigSource;
18+
result: ConfigLoaderStatus;
19+
};
20+
21+
export type ConfigurationLoadAttempt = Promise<ConfigLoadResult>;

src/index.spec.ts

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,12 @@ describe('initialization options', () => {
539539
});
540540

541541
it('only fetches/does initialization workload once per API key if init is called multiple times concurrently', async () => {
542+
// IMPORTANT NOTE
543+
// Initializing the SDK with multiple different SDK Keys is undefined behaviour as the EppoClient is accessed via
544+
// `getInstance` which returns a singleton. Initializing with multiple keys is _not yet supported_, so in this test,
545+
// we just use the same key over and over. The more intricate parts of configuration loading and storing will
546+
// silently break and fail the tests in unexpected ways if we use multiple keys.
547+
542548
let callCount = 0;
543549

544550
global.fetch = jest.fn(() => {
@@ -553,15 +559,15 @@ describe('initialization options', () => {
553559
const inits: Promise<EppoClient>[] = [];
554560
[
555561
'KEY_1',
556-
'KEY_2',
557562
'KEY_1',
558-
'KEY_2',
559563
'KEY_1',
560-
'KEY_2',
561-
'KEY_3',
562564
'KEY_1',
563-
'KEY_2',
564-
'KEY_3',
565+
'KEY_1',
566+
'KEY_1',
567+
'KEY_1',
568+
'KEY_1',
569+
'KEY_1',
570+
'KEY_1',
565571
].forEach((varyingAPIKey) => {
566572
inits.push(
567573
init({
@@ -580,12 +586,12 @@ describe('initialization options', () => {
580586
const client = await Promise.race(inits);
581587
await Promise.all(inits);
582588

583-
expect(callCount).toBe(3);
589+
expect(callCount).toBe(1);
584590
callCount = 0;
585591
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
586592

587593
const reInits: Promise<EppoClient>[] = [];
588-
['KEY_1', 'KEY_2', 'KEY_3', 'KEY_4'].forEach((varyingAPIKey) => {
594+
['KEY_1', 'KEY_1', 'KEY_1', 'KEY_1'].forEach((varyingAPIKey) => {
589595
reInits.push(
590596
init({
591597
apiKey: varyingAPIKey,
@@ -598,7 +604,7 @@ describe('initialization options', () => {
598604

599605
await Promise.all(reInits);
600606

601-
expect(callCount).toBe(4);
607+
expect(callCount).toBe(1);
602608
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
603609
});
604610

@@ -764,6 +770,9 @@ describe('initialization options', () => {
764770
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
765771
});
766772

773+
// This test sets up the EppoClient to fail to load a configuration.
774+
// If init is told to skip the initial request and there is no persistent store with data,
775+
// the client throws an error (unless throwOnFailedInitialization = false).
767776
it('skips initial request', async () => {
768777
let callCount = 0;
769778

@@ -780,6 +789,7 @@ describe('initialization options', () => {
780789
await init({
781790
apiKey,
782791
baseUrl,
792+
throwOnFailedInitialization: false,
783793
assignmentLogger: mockLogger,
784794
skipInitialRequest: true,
785795
});
@@ -1137,6 +1147,69 @@ describe('initialization options', () => {
11371147
);
11381148
});
11391149
});
1150+
1151+
describe('advanced initialization conditions', () => {
1152+
it('skips the fetch and uses the persistent store when unexpired', async () => {
1153+
// This test sets up a case where the persistent store has unexpired entries, but fails to load them into memory
1154+
// before the fetching code checks to see whether the entries are expired. In this case, the fetch can abort but
1155+
// we want the initialization routine to now wait for the config to finish loading.
1156+
const entriesPromise = new DeferredPromise<Record<string, Flag>>();
1157+
1158+
const mockStore: IAsyncStore<Flag> = {
1159+
isInitialized() {
1160+
return false; // mock that entries have not been save from a fetch.
1161+
},
1162+
async isExpired() {
1163+
return false; // prevents a fetch
1164+
},
1165+
async entries() {
1166+
return entriesPromise.promise;
1167+
},
1168+
async setEntries(entries) {
1169+
// pass
1170+
},
1171+
};
1172+
1173+
let callCount = 0;
1174+
const mockStoreEntries = { flags: {} } as unknown as Record<string, Flag>;
1175+
global.fetch = jest.fn(() => {
1176+
callCount++;
1177+
return Promise.resolve({
1178+
ok: true,
1179+
status: 200,
1180+
json: () => Promise.resolve({ flags: mockStoreEntries }),
1181+
});
1182+
}) as jest.Mock;
1183+
1184+
const mockLogger = td.object<IAssignmentLogger>();
1185+
let clientInitialized = false;
1186+
const initPromise = init({
1187+
apiKey,
1188+
baseUrl,
1189+
persistentStore: mockStore,
1190+
forceReinitialize: true,
1191+
assignmentLogger: mockLogger,
1192+
}).then((client) => {
1193+
clientInitialized = true;
1194+
return client;
1195+
});
1196+
1197+
expect(callCount).toBe(0);
1198+
expect(clientInitialized).toBe(false);
1199+
1200+
// Complete the "load from cache"
1201+
if (entriesPromise.resolve) {
1202+
entriesPromise.resolve(mockConfigResponse.flags);
1203+
} else {
1204+
throw 'Error running test';
1205+
}
1206+
1207+
// Await so it can finish its initialization before this test proceeds
1208+
const client = await initPromise;
1209+
expect(callCount).toBe(0);
1210+
expect(client.getStringAssignment(flagKey, 'subject', {}, 'default-value')).toBe('control');
1211+
});
1212+
});
11401213
});
11411214

11421215
describe('getConfigUrl function', () => {
@@ -1331,7 +1404,7 @@ describe('EppoClient config', () => {
13311404
return Promise.resolve({
13321405
ok: true,
13331406
status: 200,
1334-
json: () => Promise.resolve({}),
1407+
json: () => Promise.resolve({ flags: {} }),
13351408
});
13361409
}) as jest.Mock;
13371410
const client = await init({
@@ -1378,3 +1451,20 @@ describe('EppoClient config', () => {
13781451
});
13791452
});
13801453
});
1454+
1455+
/**
1456+
* A wrapper for a promise which allows for later resolution.
1457+
*/
1458+
class DeferredPromise<T> {
1459+
promise: Promise<T>;
1460+
resolve?: (value: PromiseLike<T> | T) => void;
1461+
reject?: (reason?: never) => void;
1462+
constructor() {
1463+
// eslint-disable-next-line @typescript-eslint/no-this-alias
1464+
const self = this;
1465+
this.promise = new Promise<T>((resolve, reject) => {
1466+
self.resolve = resolve;
1467+
self.reject = reject;
1468+
});
1469+
}
1470+
}

0 commit comments

Comments
 (0)