Skip to content

Commit c892b5f

Browse files
authored
Common configuration requestor/poller (#35)
* move poller over * poller worked into init * bump version number * use yarn with the makefile prepare commands * test for happy path client fetch * initial rery function working * selective jest mocking * better test case * better logging and improved poller test * make compatible with node sdk * changes for node sdk server to work * clear timeout when stopped * remove dummy test * remove unused import
1 parent 0898d84 commit c892b5f

File tree

12 files changed

+1759
-1141
lines changed

12 files changed

+1759
-1141
lines changed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ test-data:
4545
.PHONY: prepare
4646
prepare: test-data
4747
rm -rf dist/
48-
tsc
49-
webpack
48+
yarn tsc
49+
yarn webpack
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
module.exports = {
1+
const jestConfig = {
22
moduleFileExtensions: ['js', 'json', 'ts'],
33
rootDir: './',
44
moduleNameMapper: {
@@ -12,9 +12,6 @@ module.exports = {
1212
collectCoverageFrom: ['**/*.(t|j)s'],
1313
coverageDirectory: 'coverage/',
1414
testEnvironment: 'node',
15-
globals: {
16-
'ts-jest': {
17-
isolatedModules: true,
18-
},
19-
},
2015
};
16+
17+
export default jestConfig;

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "2.0.0",
3+
"version": "2.1.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [
@@ -39,7 +39,7 @@
3939
},
4040
"homepage": "https://github.com/Eppo-exp/js-client-sdk-common#readme",
4141
"devDependencies": {
42-
"@types/jest": "^28.1.1",
42+
"@types/jest": "^29.5.11",
4343
"@types/md5": "^2.3.2",
4444
"@typescript-eslint/eslint-plugin": "^5.13.0",
4545
"@typescript-eslint/parser": "^5.13.0",
@@ -49,12 +49,12 @@
4949
"eslint-plugin-import": "^2.25.4",
5050
"eslint-plugin-prettier": "^4.0.0",
5151
"eslint-plugin-promise": "^6.0.0",
52-
"jest": "^28.1.1",
53-
"jest-environment-jsdom": "^28.1.1",
52+
"jest": "^29.7.0",
53+
"jest-environment-jsdom": "^29.7.0",
5454
"prettier": "^2.7.1",
5555
"terser-webpack-plugin": "^5.3.3",
56-
"testdouble": "^3.16.6",
57-
"ts-jest": "^28.0.5",
56+
"testdouble": "^3.20.1",
57+
"ts-jest": "^29.1.1",
5858
"ts-loader": "^9.3.1",
5959
"ts-node": "^10.9.1",
6060
"typescript": "^4.7.4",

src/client/eppo-client.spec.ts

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import axios from 'axios';
55
import * as td from 'testdouble';
6-
import mock from 'xhr-mock';
6+
import mock, { MockResponse } from 'xhr-mock';
77

88
import {
99
IAssignmentTestCase,
@@ -16,19 +16,19 @@ import {
1616
import { IAssignmentHooks } from '../assignment-hooks';
1717
import { IAssignmentLogger } from '../assignment-logger';
1818
import { IConfigurationStore } from '../configuration-store';
19-
import { MAX_EVENT_QUEUE_SIZE } from '../constants';
19+
import { MAX_EVENT_QUEUE_SIZE, POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
2020
import { OperatorType } from '../dto/rule-dto';
2121
import { EppoValue } from '../eppo_value';
2222
import ExperimentConfigurationRequestor from '../experiment-configuration-requestor';
2323
import HttpClient from '../http-client';
2424

25-
import EppoClient from './eppo-client';
25+
import EppoClient, { ExperimentConfigurationRequestParameters } from './eppo-client';
2626

2727
// eslint-disable-next-line @typescript-eslint/no-var-requires
2828
const packageJson = require('../../package.json');
2929

3030
class TestConfigurationStore implements IConfigurationStore {
31-
private store = {};
31+
private store: Record<string, string> = {};
3232

3333
public get<T>(key: string): T {
3434
const rval = this.store[key];
@@ -1101,3 +1101,172 @@ describe(' EppoClient getAssignment From Obfuscated RAC', () => {
11011101
});
11021102
}
11031103
});
1104+
1105+
describe('Eppo Client constructed with configuration request parameters', () => {
1106+
let client: EppoClient;
1107+
let storage: IConfigurationStore;
1108+
let requestConfiguration: ExperimentConfigurationRequestParameters;
1109+
let mockServerResponseFunc: (res: MockResponse) => MockResponse;
1110+
1111+
const racBody = JSON.stringify(readMockRacResponse(MOCK_RAC_RESPONSE_FILE));
1112+
const flagKey = 'randomization_algo';
1113+
const subjectForGreenVariation = 'subject-identiferA';
1114+
1115+
const maxRetryDelay = POLL_INTERVAL_MS * POLL_JITTER_PCT;
1116+
1117+
beforeAll(() => {
1118+
mock.setup();
1119+
mock.get(/randomized_assignment\/v3\/config*/, (_req, res) => {
1120+
return mockServerResponseFunc(res);
1121+
});
1122+
});
1123+
1124+
beforeEach(() => {
1125+
storage = new TestConfigurationStore();
1126+
requestConfiguration = {
1127+
apiKey: 'dummy key',
1128+
sdkName: 'js-client-sdk-common',
1129+
sdkVersion: packageJson.version,
1130+
};
1131+
mockServerResponseFunc = (res) => res.status(200).body(racBody);
1132+
1133+
// We only want to fake setTimeout() and clearTimeout()
1134+
jest.useFakeTimers({
1135+
advanceTimers: true,
1136+
doNotFake: [
1137+
'Date',
1138+
'hrtime',
1139+
'nextTick',
1140+
'performance',
1141+
'queueMicrotask',
1142+
'requestAnimationFrame',
1143+
'cancelAnimationFrame',
1144+
'requestIdleCallback',
1145+
'cancelIdleCallback',
1146+
'setImmediate',
1147+
'clearImmediate',
1148+
'setInterval',
1149+
'clearInterval',
1150+
],
1151+
});
1152+
});
1153+
1154+
afterEach(() => {
1155+
jest.clearAllTimers();
1156+
jest.useRealTimers();
1157+
});
1158+
1159+
afterAll(() => {
1160+
mock.teardown();
1161+
});
1162+
1163+
it('Fetches initial configuration', async () => {
1164+
client = new EppoClient(storage, requestConfiguration);
1165+
client.setIsGracefulFailureMode(false);
1166+
// no configuration loaded
1167+
let variation = client.getAssignment(subjectForGreenVariation, flagKey);
1168+
expect(variation).toBeNull();
1169+
// have client fetch configurations
1170+
await client.fetchFlagConfigurations();
1171+
variation = client.getAssignment(subjectForGreenVariation, flagKey);
1172+
expect(variation).toBe('green');
1173+
});
1174+
1175+
it.each([
1176+
{ pollAfterSuccessfulInitialization: false },
1177+
{ pollAfterSuccessfulInitialization: true },
1178+
])('retries initial configuration request with config %p', async (configModification) => {
1179+
let callCount = 0;
1180+
mockServerResponseFunc = (res) => {
1181+
if (++callCount === 1) {
1182+
// Throw an error for the first call
1183+
return res.status(500);
1184+
} else {
1185+
// Return a mock object for subsequent calls
1186+
return res.status(200).body(racBody);
1187+
}
1188+
};
1189+
1190+
const { pollAfterSuccessfulInitialization } = configModification;
1191+
requestConfiguration = {
1192+
...requestConfiguration,
1193+
pollAfterSuccessfulInitialization,
1194+
};
1195+
client = new EppoClient(storage, requestConfiguration);
1196+
client.setIsGracefulFailureMode(false);
1197+
// no configuration loaded
1198+
let variation = client.getAssignment(subjectForGreenVariation, flagKey);
1199+
expect(variation).toBeNull();
1200+
1201+
// By not awaiting (yet) only the first attempt should be fired off before test execution below resumes
1202+
const fetchPromise = client.fetchFlagConfigurations();
1203+
1204+
// Advance timers mid-init to allow retrying
1205+
await jest.advanceTimersByTimeAsync(maxRetryDelay);
1206+
1207+
// Await so it can finish its initialization before this test proceeds
1208+
await fetchPromise;
1209+
1210+
variation = client.getAssignment(subjectForGreenVariation, flagKey);
1211+
expect(variation).toBe('green');
1212+
expect(callCount).toBe(2);
1213+
1214+
await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
1215+
// By default, no more polling
1216+
expect(callCount).toBe(pollAfterSuccessfulInitialization ? 3 : 2);
1217+
});
1218+
1219+
it.each([
1220+
{ pollAfterFailedInitialization: false, throwOnFailedInitialization: false },
1221+
{ pollAfterFailedInitialization: false, throwOnFailedInitialization: true },
1222+
{ pollAfterFailedInitialization: true, throwOnFailedInitialization: false },
1223+
{ pollAfterFailedInitialization: true, throwOnFailedInitialization: true },
1224+
])('initial configuration request fails with config %p', async (configModification) => {
1225+
let callCount = 0;
1226+
mockServerResponseFunc = (res) => {
1227+
if (++callCount === 1) {
1228+
// Throw an error for initialization call
1229+
return res.status(500);
1230+
} else {
1231+
// Return a mock object for subsequent calls
1232+
return res.status(200).body(racBody);
1233+
}
1234+
};
1235+
1236+
const { pollAfterFailedInitialization, throwOnFailedInitialization } = configModification;
1237+
1238+
// Note: fake time does not play well with errors bubbled up after setTimeout (event loop,
1239+
// timeout queue, message queue stuff) so we don't allow retries when rethrowing.
1240+
const numInitialRequestRetries = 0;
1241+
1242+
requestConfiguration = {
1243+
...requestConfiguration,
1244+
numInitialRequestRetries,
1245+
throwOnFailedInitialization,
1246+
pollAfterFailedInitialization,
1247+
};
1248+
client = new EppoClient(storage, requestConfiguration);
1249+
client.setIsGracefulFailureMode(false);
1250+
// no configuration loaded
1251+
expect(client.getAssignment(subjectForGreenVariation, flagKey)).toBeNull();
1252+
1253+
// By not awaiting (yet) only the first attempt should be fired off before test execution below resumes
1254+
if (throwOnFailedInitialization) {
1255+
await expect(client.fetchFlagConfigurations()).rejects.toThrow();
1256+
} else {
1257+
await expect(client.fetchFlagConfigurations()).resolves.toBeUndefined();
1258+
}
1259+
expect(callCount).toBe(1);
1260+
// still no configuration loaded
1261+
expect(client.getAssignment(subjectForGreenVariation, flagKey)).toBeNull();
1262+
1263+
// Advance timers so a post-init poll can take place
1264+
await jest.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 1.5);
1265+
1266+
// if pollAfterFailedInitialization = true, we will poll later and get a config, otherwise not
1267+
expect(callCount).toBe(pollAfterFailedInitialization ? 2 : 1);
1268+
expect(client.getAssignment(subjectForGreenVariation, flagKey)).toBe(
1269+
pollAfterFailedInitialization ? 'green' : null,
1270+
);
1271+
});
1272+
});

src/client/eppo-client.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import axios from 'axios';
12
import * as md5 from 'md5';
23

34
import {
@@ -14,12 +15,22 @@ import {
1415
NullableHoldoutVariationType,
1516
} from '../assignment-logger';
1617
import { IConfigurationStore } from '../configuration-store';
17-
import { MAX_EVENT_QUEUE_SIZE } from '../constants';
18+
import {
19+
BASE_URL as DEFAULT_BASE_URL,
20+
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
21+
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
22+
DEFAULT_REQUEST_TIMEOUT_MS as DEFAULT_REQUEST_TIMEOUT_MS,
23+
MAX_EVENT_QUEUE_SIZE,
24+
POLL_INTERVAL_MS,
25+
} from '../constants';
1826
import { IAllocation } from '../dto/allocation-dto';
1927
import { IExperimentConfiguration } from '../dto/experiment-configuration-dto';
2028
import { IVariation } from '../dto/variation-dto';
2129
import { EppoValue, ValueType } from '../eppo_value';
30+
import ExperimentConfigurationRequestor from '../experiment-configuration-requestor';
31+
import HttpClient from '../http-client';
2232
import { getMD5Hash } from '../obfuscation';
33+
import initPoller, { IPoller } from '../poller';
2334
import { findMatchingRule } from '../rule_evaluator';
2435
import { getShard, isShardInRange } from '../shard';
2536
import { validateNotBlank } from '../validation';
@@ -97,15 +108,102 @@ export interface IEppoClient {
97108
subjectAttributes?: Record<string, any>,
98109
assignmentHooks?: IAssignmentHooks,
99110
): object | null;
111+
112+
setLogger(logger: IAssignmentLogger): void;
113+
114+
useLRUInMemoryAssignmentCache(maxSize: number): void;
115+
116+
useCustomAssignmentCache(cache: AssignmentCache<Cacheable>): void;
117+
118+
fetchFlagConfigurations(): void;
119+
120+
stopPolling(): void;
121+
122+
setIsGracefulFailureMode(gracefulFailureMode: boolean): void;
100123
}
101124

125+
export type ExperimentConfigurationRequestParameters = {
126+
apiKey: string;
127+
sdkVersion: string;
128+
sdkName: string;
129+
baseUrl?: string;
130+
requestTimeoutMs?: number;
131+
numInitialRequestRetries?: number;
132+
numPollRequestRetries?: number;
133+
pollAfterSuccessfulInitialization?: boolean;
134+
pollAfterFailedInitialization?: boolean;
135+
throwOnFailedInitialization?: boolean;
136+
};
137+
102138
export default class EppoClient implements IEppoClient {
103139
private queuedEvents: IAssignmentEvent[] = [];
104140
private assignmentLogger: IAssignmentLogger | undefined;
105141
private isGracefulFailureMode = true;
106142
private assignmentCache: AssignmentCache<Cacheable> | undefined;
143+
private configurationStore: IConfigurationStore;
144+
private configurationRequestConfig: ExperimentConfigurationRequestParameters | undefined;
145+
private requestPoller: IPoller | undefined;
146+
147+
constructor(
148+
configurationStore: IConfigurationStore,
149+
configurationRequestConfig?: ExperimentConfigurationRequestParameters,
150+
) {
151+
this.configurationStore = configurationStore;
152+
this.configurationRequestConfig = configurationRequestConfig;
153+
}
154+
155+
public async fetchFlagConfigurations() {
156+
if (!this.configurationRequestConfig) {
157+
throw new Error(
158+
'Eppo SDK unable to fetch flag configurations without a request configuration',
159+
);
160+
}
161+
162+
if (this.requestPoller) {
163+
// if fetchFlagConfigurations() was previously called, stop any polling process from that call
164+
this.requestPoller.stop();
165+
}
166+
167+
const axiosInstance = axios.create({
168+
baseURL: this.configurationRequestConfig.baseUrl || DEFAULT_BASE_URL,
169+
timeout: this.configurationRequestConfig.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
170+
});
171+
const httpClient = new HttpClient(axiosInstance, {
172+
apiKey: this.configurationRequestConfig.apiKey,
173+
sdkName: this.configurationRequestConfig.sdkName,
174+
sdkVersion: this.configurationRequestConfig.sdkVersion,
175+
});
176+
const configurationRequestor = new ExperimentConfigurationRequestor(
177+
this.configurationStore,
178+
httpClient,
179+
);
107180

108-
constructor(private configurationStore: IConfigurationStore) {}
181+
this.requestPoller = initPoller(
182+
POLL_INTERVAL_MS,
183+
configurationRequestor.fetchAndStoreConfigurations.bind(configurationRequestor),
184+
{
185+
maxStartRetries:
186+
this.configurationRequestConfig.numInitialRequestRetries ??
187+
DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES,
188+
maxPollRetries:
189+
this.configurationRequestConfig.numPollRequestRetries ??
190+
DEFAULT_POLL_CONFIG_REQUEST_RETRIES,
191+
pollAfterSuccessfulStart:
192+
this.configurationRequestConfig.pollAfterSuccessfulInitialization ?? false,
193+
pollAfterFailedStart:
194+
this.configurationRequestConfig.pollAfterFailedInitialization ?? false,
195+
errorOnFailedStart: this.configurationRequestConfig.throwOnFailedInitialization ?? false,
196+
},
197+
);
198+
199+
await this.requestPoller.start();
200+
}
201+
202+
public stopPolling() {
203+
if (this.requestPoller) {
204+
this.requestPoller.stop();
205+
}
206+
}
109207

110208
// @deprecated getAssignment is deprecated in favor of the typed get<Type>Assignment methods
111209
public getAssignment(

0 commit comments

Comments
 (0)