Skip to content

Commit a4c27a2

Browse files
authored
[FSSDK-11125] implement CMAB client (#1010)
1 parent 6861f65 commit a4c27a2

File tree

3 files changed

+475
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)