Skip to content

Commit ef217c4

Browse files
committed
[FSSDK-11125] implement CMAB client
1 parent 3f02f82 commit ef217c4

File tree

2 files changed

+344
-3
lines changed

2 files changed

+344
-3
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import { describe, it, expect, vi, Mocked, Mock, MockInstance, beforeEach, afterEach } from 'vitest';
2+
3+
import { DefaultCmabClient } from './cmab_client';
4+
import { getMockAbortableRequest, getMockRequestHandler } from '../../../tests/mock/mock_request_handler';
5+
import { RequestHandler } from '../../../utils/http_request_handler/http';
6+
import { advanceTimersByTime, exhaustMicrotasks } from '../../../tests/testUtils';
7+
import { OptimizelyError } from '../../../error/optimizly_error';
8+
9+
const mockSuccessResponse = (variation: string) => Promise.resolve({
10+
statusCode: 200,
11+
body: JSON.stringify({
12+
predictions: [
13+
{
14+
variation_id: variation,
15+
},
16+
],
17+
}),
18+
headers: {}
19+
});
20+
21+
const mockErrorResponse = (statusCode: number) => Promise.resolve({
22+
statusCode,
23+
body: '',
24+
headers: {},
25+
});
26+
27+
const assertRequest = (
28+
call: number,
29+
mockRequestHandler: MockInstance<RequestHandler['makeRequest']>,
30+
experimentId: string,
31+
userId: string,
32+
attributes: Record<string, any>,
33+
cmabUuid: string,
34+
) => {
35+
const [requestUrl, headers, method, data] = mockRequestHandler.mock.calls[call];
36+
expect(requestUrl).toBe(`https://prediction.cmab.optimizely.com/predict/${experimentId}`);
37+
expect(method).toBe('POST');
38+
expect(headers).toEqual({
39+
'Content-Type': 'application/json',
40+
});
41+
42+
const parsedData = JSON.parse(data!);
43+
expect(parsedData.instances).toEqual([
44+
{
45+
visitorId: userId,
46+
experimentId,
47+
attributes: Object.keys(attributes).map((key) => ({
48+
id: key,
49+
value: attributes[key],
50+
type: 'custom_attribute',
51+
})),
52+
cmabUUID: cmabUuid,
53+
}
54+
]);
55+
};
56+
57+
describe('DefaultCmabClient', () => {
58+
it('should fetch variation using correct parameters', async () => {
59+
const requestHandler = getMockRequestHandler();
60+
61+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
62+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockSuccessResponse('var123')));
63+
64+
const cmabClient = new DefaultCmabClient({
65+
requestHandler,
66+
});
67+
68+
const experimentId = '123';
69+
const userId = 'user123';
70+
const attributes = {
71+
browser: 'chrome',
72+
isMobile: true,
73+
};
74+
const cmabUuid = 'uuid123';
75+
76+
const variation = await cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid);
77+
78+
expect(variation).toBe('var123');
79+
assertRequest(0, mockMakeRequest, experimentId, userId, attributes, cmabUuid);
80+
});
81+
82+
it('should retry fetch if retryConfig is provided', async () => {
83+
const requestHandler = getMockRequestHandler();
84+
85+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
86+
mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')))
87+
.mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
88+
.mockReturnValueOnce(getMockAbortableRequest(mockSuccessResponse('var123')));
89+
90+
const cmabClient = new DefaultCmabClient({
91+
requestHandler,
92+
retryConfig: {
93+
maxRetries: 5,
94+
},
95+
});
96+
97+
const experimentId = '123';
98+
const userId = 'user123';
99+
const attributes = {
100+
browser: 'chrome',
101+
isMobile: true,
102+
};
103+
const cmabUuid = 'uuid123';
104+
105+
const variation = await cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid);
106+
107+
expect(variation).toBe('var123');
108+
expect(mockMakeRequest.mock.calls.length).toBe(3);
109+
for(let i = 0; i < 3; i++) {
110+
assertRequest(i, mockMakeRequest, experimentId, userId, attributes, cmabUuid);
111+
}
112+
});
113+
114+
it('should use backoff provider if provided', async () => {
115+
vi.useFakeTimers();
116+
117+
const requestHandler = getMockRequestHandler();
118+
119+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
120+
mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')))
121+
.mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
122+
.mockReturnValueOnce(getMockAbortableRequest(mockErrorResponse(500)))
123+
.mockReturnValueOnce(getMockAbortableRequest(mockSuccessResponse('var123')));
124+
125+
const backoffProvider = () => {
126+
let call = 0;
127+
const values = [100, 200, 300];
128+
return {
129+
reset: () => {},
130+
backoff: () => {
131+
return values[call++];
132+
},
133+
};
134+
}
135+
136+
const cmabClient = new DefaultCmabClient({
137+
requestHandler,
138+
retryConfig: {
139+
maxRetries: 5,
140+
backoffProvider,
141+
},
142+
});
143+
144+
const experimentId = '123';
145+
const userId = 'user123';
146+
const attributes = {
147+
browser: 'chrome',
148+
isMobile: true,
149+
};
150+
const cmabUuid = 'uuid123';
151+
152+
const fetchPromise = cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid);
153+
154+
await exhaustMicrotasks();
155+
expect(mockMakeRequest.mock.calls.length).toBe(1);
156+
157+
// first backoff is 100ms, should not retry yet
158+
await advanceTimersByTime(90);
159+
await exhaustMicrotasks();
160+
expect(mockMakeRequest.mock.calls.length).toBe(1);
161+
162+
// first backoff is 100ms, should retry now
163+
await advanceTimersByTime(10);
164+
await exhaustMicrotasks();
165+
expect(mockMakeRequest.mock.calls.length).toBe(2);
166+
167+
// second backoff is 200ms, should not retry 2nd time yet
168+
await advanceTimersByTime(150);
169+
await exhaustMicrotasks();
170+
expect(mockMakeRequest.mock.calls.length).toBe(2);
171+
172+
// second backoff is 200ms, should retry 2nd time now
173+
await advanceTimersByTime(50);
174+
await exhaustMicrotasks();
175+
expect(mockMakeRequest.mock.calls.length).toBe(3);
176+
177+
// third backoff is 300ms, should not retry 3rd time yet
178+
await advanceTimersByTime(280);
179+
await exhaustMicrotasks();
180+
expect(mockMakeRequest.mock.calls.length).toBe(3);
181+
182+
// third backoff is 300ms, should retry 3rd time now
183+
await advanceTimersByTime(20);
184+
await exhaustMicrotasks();
185+
expect(mockMakeRequest.mock.calls.length).toBe(4);
186+
187+
const variation = await fetchPromise;
188+
189+
expect(variation).toBe('var123');
190+
expect(mockMakeRequest.mock.calls.length).toBe(4);
191+
for(let i = 0; i < 4; i++) {
192+
assertRequest(i, mockMakeRequest, experimentId, userId, attributes, cmabUuid);
193+
}
194+
vi.useRealTimers();
195+
});
196+
197+
it('should reject the promise after retries are exhausted', async () => {
198+
const requestHandler = getMockRequestHandler();
199+
200+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
201+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.reject('error')));
202+
203+
const cmabClient = new DefaultCmabClient({
204+
requestHandler,
205+
retryConfig: {
206+
maxRetries: 5,
207+
},
208+
});
209+
210+
const experimentId = '123';
211+
const userId = 'user123';
212+
const attributes = {
213+
browser: 'chrome',
214+
isMobile: true,
215+
};
216+
const cmabUuid = 'uuid123';
217+
218+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toThrow();
219+
expect(mockMakeRequest.mock.calls.length).toBe(6);
220+
});
221+
222+
it('should reject the promise after retries are exhausted with error status', async () => {
223+
const requestHandler = getMockRequestHandler();
224+
225+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
226+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockErrorResponse(500)));
227+
228+
const cmabClient = new DefaultCmabClient({
229+
requestHandler,
230+
retryConfig: {
231+
maxRetries: 5,
232+
},
233+
});
234+
235+
const experimentId = '123';
236+
const userId = 'user123';
237+
const attributes = {
238+
browser: 'chrome',
239+
isMobile: true,
240+
};
241+
const cmabUuid = 'uuid123';
242+
243+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toThrow();
244+
expect(mockMakeRequest.mock.calls.length).toBe(6);
245+
});
246+
247+
it('should not retry if retryConfig is not provided', async () => {
248+
const requestHandler = getMockRequestHandler();
249+
250+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
251+
mockMakeRequest.mockReturnValueOnce(getMockAbortableRequest(Promise.reject('error')));
252+
253+
const cmabClient = new DefaultCmabClient({
254+
requestHandler,
255+
});
256+
257+
const experimentId = '123';
258+
const userId = 'user123';
259+
const attributes = {
260+
browser: 'chrome',
261+
isMobile: true,
262+
};
263+
const cmabUuid = 'uuid123';
264+
265+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toThrow();
266+
expect(mockMakeRequest.mock.calls.length).toBe(1);
267+
});
268+
269+
it('should reject the promise if response status code is not 200', async () => {
270+
const requestHandler = getMockRequestHandler();
271+
272+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
273+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(mockErrorResponse(500)));
274+
275+
const cmabClient = new DefaultCmabClient({
276+
requestHandler,
277+
});
278+
279+
const experimentId = '123';
280+
const userId = 'user123';
281+
const attributes = {
282+
browser: 'chrome',
283+
isMobile: true,
284+
};
285+
const cmabUuid = 'uuid123';
286+
287+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toMatchObject(
288+
new OptimizelyError('CMAB_FETCH_FAILED', 500),
289+
);
290+
});
291+
292+
it('should reject the promise if api response is not valid', async () => {
293+
const requestHandler = getMockRequestHandler();
294+
295+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
296+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.resolve({
297+
statusCode: 200,
298+
body: JSON.stringify({
299+
predictions: [],
300+
}),
301+
headers: {},
302+
})));
303+
304+
const cmabClient = new DefaultCmabClient({
305+
requestHandler,
306+
});
307+
308+
const experimentId = '123';
309+
const userId = 'user123';
310+
const attributes = {
311+
browser: 'chrome',
312+
isMobile: true,
313+
};
314+
const cmabUuid = 'uuid123';
315+
316+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toMatchObject(
317+
new OptimizelyError('INVALID_CMAB_RESPONSE'),
318+
);
319+
});
320+
321+
it('should reject the promise if requestHandler.makeRequest rejects', async () => {
322+
const requestHandler = getMockRequestHandler();
323+
324+
const mockMakeRequest: MockInstance<RequestHandler['makeRequest']> = requestHandler.makeRequest;
325+
mockMakeRequest.mockReturnValue(getMockAbortableRequest(Promise.reject('error')));
326+
327+
const cmabClient = new DefaultCmabClient({
328+
requestHandler,
329+
});
330+
331+
const experimentId = '123';
332+
const userId = 'user123';
333+
const attributes = {
334+
browser: 'chrome',
335+
isMobile: true,
336+
};
337+
const cmabUuid = 'uuid123';
338+
339+
await expect(cmabClient.fetchVariation(experimentId, userId, attributes, cmabUuid)).rejects.toThrow('error');
340+
});
341+
});

lib/core/decision_service/cmab/cmab_client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface CmabClient {
2929
experimentId: string,
3030
userId: string,
3131
attributes: UserAttributes,
32-
cmabUuid: string
32+
cmabUuid: string,
3333
): Promise<string>
3434
}
3535

@@ -58,7 +58,7 @@ export class DefaultCmabClient implements CmabClient {
5858
experimentId: string,
5959
userId: string,
6060
attributes: UserAttributes,
61-
cmabUuid: string
61+
cmabUuid: string,
6262
): Promise<string> {
6363
const url = sprintf(CMAB_PREDICTION_ENDPOINT, experimentId);
6464

@@ -98,7 +98,7 @@ export class DefaultCmabClient implements CmabClient {
9898
data,
9999
).responsePromise;
100100

101-
if (isSuccessStatusCode(response.statusCode)) {
101+
if (!isSuccessStatusCode(response.statusCode)) {
102102
return Promise.reject(new OptimizelyError(CMAB_FETCH_FAILED, response.statusCode));
103103
}
104104

0 commit comments

Comments
 (0)