Skip to content

Commit fb32e0b

Browse files
Enhance CMAB client error handling and logging; add unit tests for fetch methods
1 parent 4edd3b9 commit fb32e0b

File tree

3 files changed

+166
-7
lines changed

3 files changed

+166
-7
lines changed

optimizely/cmab/cmab_client.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -68,29 +68,29 @@ def fetch_decision(
6868
return variation_id
6969

7070
except requests.RequestException as e:
71-
self.logger.error(f"Error fetching CMAB decision: {e}")
72-
pass
71+
self.logger.error(Errors.CMAB_FETCH_FAILED.format(str(e)))
72+
return None
7373

7474
def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
7575
headers = {'Content-Type': 'application/json'}
7676
try:
7777
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=MAX_WAIT_TIME)
7878
except requests.exceptions.RequestException as e:
79-
self.logger.exception(str(e))
79+
self.logger.exception(Errors.CMAB_FETCH_FAILED.format(str(e)))
8080
return None
8181

8282
if not 200 <= response.status_code < 300:
83-
self.logger.exception(f'CMAB Request failed with status code: {response.status_code}')
83+
self.logger.exception(Errors.CMAB_FETCH_FAILED.format(str(response.status_code)))
8484
return None
8585

8686
try:
8787
body = response.json()
8888
except json.JSONDecodeError as e:
89-
self.logger.exception(str(e))
89+
self.logger.exception(Errors.INVALID_CMAB_FETCH_RESPONSE)
9090
return None
9191

9292
if not self.validate_response(body):
93-
self.logger.exception('Invalid response')
93+
self.logger.exception(Errors.INVALID_CMAB_FETCH_RESPONSE)
9494
return None
9595

9696
return str(body['predictions'][0]['variation_id'])
@@ -115,5 +115,5 @@ def _do_fetch_with_retry(self, url: str, request_body: dict, retry_config: CmabR
115115
self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
116116
time.sleep(backoff)
117117
backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1), retry_config.max_backoff)
118-
self.logger.error("Exhausted all retries for CMAB request.")
118+
self.logger.error(Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.'))
119119
return None

optimizely/helpers/enums.py

+2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ class Errors:
127127
ODP_INVALID_DATA: Final = 'ODP data is not valid.'
128128
ODP_INVALID_ACTION: Final = 'ODP action is not valid (cannot be empty).'
129129
MISSING_SDK_KEY: Final = 'SDK key not provided/cannot be found in the datafile.'
130+
CMAB_FETCH_FAILED: Final = 'CMAB decision fetch failed with status: {}'
131+
INVALID_CMAB_FETCH_RESPONSE = 'Invalid CMAB fetch response'
130132

131133

132134
class ForcedDecisionLogs:

tests/test_cmab_client.py

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import unittest
2+
import json
3+
from unittest.mock import MagicMock, patch, call
4+
from optimizely.cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
5+
from requests.exceptions import RequestException
6+
from optimizely.helpers.enums import Errors
7+
8+
class TestDefaultCmabClient_do_fetch(unittest.TestCase):
9+
def setUp(self):
10+
self.mock_http_client = MagicMock()
11+
self.mock_logger = MagicMock()
12+
self.client = DefaultCmabClient(http_client=self.mock_http_client, logger=self.mock_logger)
13+
14+
def test_do_fetch_success(self):
15+
mock_response = MagicMock()
16+
mock_response.status_code = 200
17+
mock_response.json.return_value = {
18+
'predictions': [{'variation_id': 'abc123'}]
19+
}
20+
self.mock_http_client.post.return_value = mock_response
21+
22+
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
23+
self.assertEqual(result, 'abc123')
24+
25+
def test_do_fetch_http_exception(self):
26+
self.mock_http_client.post.side_effect = RequestException('Connection error')
27+
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
28+
self.assertIsNone(result)
29+
self.mock_logger.exception.assert_called_with(Errors.CMAB_FETCH_FAILED.format('Connection error'))
30+
31+
def test_do_fetch_non_2xx_status(self):
32+
mock_response = MagicMock()
33+
mock_response.status_code = 500
34+
self.mock_http_client.post.return_value = mock_response
35+
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
36+
self.assertIsNone(result)
37+
self.mock_logger.exception.assert_called_with(Errors.CMAB_FETCH_FAILED.format(str(mock_response.status_code)))
38+
39+
def test_do_fetch_invalid_json(self):
40+
mock_response = MagicMock()
41+
mock_response.status_code = 200
42+
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
43+
self.mock_http_client.post.return_value = mock_response
44+
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
45+
self.assertIsNone(result)
46+
self.mock_logger.exception.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
47+
48+
def test_do_fetch_invalid_response_structure(self):
49+
mock_response = MagicMock()
50+
mock_response.status_code = 200
51+
mock_response.json.return_value = {'no_predictions': []}
52+
self.mock_http_client.post.return_value = mock_response
53+
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
54+
self.assertIsNone(result)
55+
self.mock_logger.exception.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
56+
57+
class TestDefaultCmabClientWithRetry(unittest.TestCase):
58+
def setUp(self):
59+
self.mock_http_client = MagicMock()
60+
self.mock_logger = MagicMock()
61+
self.retry_config = CmabRetryConfig(max_retries=2, initial_backoff=0.01, max_backoff=1, backoff_multiplier=2)
62+
self.client = DefaultCmabClient(
63+
http_client=self.mock_http_client,
64+
logger=self.mock_logger,
65+
retry_config=self.retry_config
66+
)
67+
68+
@patch("time.sleep", return_value=None)
69+
def test_do_fetch_with_retry_success_on_first_try(self, _):
70+
mock_response = MagicMock()
71+
mock_response.status_code = 200
72+
mock_response.json.return_value = {
73+
"predictions": [{"variation_id": "abc123"}]
74+
}
75+
self.mock_http_client.post.return_value = mock_response
76+
77+
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
78+
self.assertEqual(result, "abc123")
79+
self.assertEqual(self.mock_http_client.post.call_count, 1)
80+
81+
@patch("time.sleep", return_value=None)
82+
def test_do_fetch_with_retry_success_on_retry(self, _):
83+
# First call fails, second call succeeds
84+
failure_response = MagicMock()
85+
failure_response.status_code = 500
86+
87+
success_response = MagicMock()
88+
success_response.status_code = 200
89+
success_response.json.return_value = {
90+
"predictions": [{"variation_id": "xyz456"}]
91+
}
92+
93+
self.mock_http_client.post.side_effect = [
94+
failure_response,
95+
success_response
96+
]
97+
98+
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
99+
self.assertEqual(result, "xyz456")
100+
self.assertEqual(self.mock_http_client.post.call_count, 2)
101+
self.mock_logger.info.assert_called_with("Retrying CMAB request (attempt: 1) after 0.01 seconds...")
102+
103+
@patch("time.sleep", return_value=None)
104+
def test_do_fetch_with_retry_exhausts_all_attempts(self, _):
105+
failure_response = MagicMock()
106+
failure_response.status_code = 500
107+
108+
self.mock_http_client.post.return_value = failure_response
109+
110+
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
111+
self.assertIsNone(result)
112+
self.assertEqual(self.mock_http_client.post.call_count, 3) # 1 original + 2 retries
113+
self.mock_logger.error.assert_called_with(Errors.CMAB_FETCH_FAILED.format("Exhausted all retries for CMAB request."))
114+
115+
class TestDefaultCmabClientFetchDecision(unittest.TestCase):
116+
def setUp(self):
117+
self.mock_http_client = MagicMock()
118+
self.mock_logger = MagicMock()
119+
self.retry_config = CmabRetryConfig(max_retries=2, initial_backoff=0.01, max_backoff=1, backoff_multiplier=2)
120+
self.client = DefaultCmabClient(
121+
http_client=self.mock_http_client,
122+
logger=self.mock_logger,
123+
retry_config=self.retry_config
124+
)
125+
self.rule_id = 'test_rule'
126+
self.user_id = 'user123'
127+
self.attributes = {'attr1': 'value1'}
128+
self.cmab_uuid = 'uuid-1234'
129+
130+
@patch.object(DefaultCmabClient, '_do_fetch', return_value='var-abc')
131+
def test_fetch_decision_success_no_retry(self, mock_do_fetch):
132+
result = self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
133+
self.assertEqual(result, 'var-abc')
134+
mock_do_fetch.assert_called_once()
135+
136+
@patch.object(DefaultCmabClient, '_do_fetch_with_retry', return_value='var-xyz')
137+
def test_fetch_decision_success_with_retry(self, mock_do_fetch_with_retry):
138+
client_with_retry = DefaultCmabClient(
139+
http_client=self.mock_http_client,
140+
logger=self.mock_logger,
141+
retry_config=self.retry_config
142+
)
143+
result = client_with_retry.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
144+
self.assertEqual(result, 'var-xyz')
145+
mock_do_fetch_with_retry.assert_called_once()
146+
147+
@patch.object(DefaultCmabClient, '_do_fetch', side_effect=RequestException("Network error"))
148+
def test_fetch_decision_request_exception(self, mock_do_fetch):
149+
result = self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
150+
self.assertIsNone(result)
151+
self.mock_logger.error.assert_called_with(Errors.CMAB_FETCH_FAILED.format("Network error"))
152+
153+
@patch.object(DefaultCmabClient, '_do_fetch', return_value=None)
154+
def test_fetch_decision_invalid_response(self, mock_do_fetch):
155+
result = self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
156+
self.assertIsNone(result)
157+
self.mock_logger.error.assert_called_once()

0 commit comments

Comments
 (0)