Skip to content

Commit 58755f1

Browse files
Add custom exceptions for CMAB client errors and enhance error handling in fetch methods
1 parent 5e6bbde commit 58755f1

File tree

3 files changed

+97
-58
lines changed

3 files changed

+97
-58
lines changed

optimizely/cmab/cmab_client.py

+45-32
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from typing import Dict, Any, Optional
1818
from optimizely import logger as _logging
1919
from optimizely.helpers.enums import Errors
20+
from optimizely.exceptions import CmabFetchError, CmabInvalidResponseError
2021

2122
# CMAB_PREDICTION_ENDPOINT is the endpoint for CMAB predictions
2223
CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"
@@ -71,20 +72,23 @@ def fetch_decision(
7172
rule_id: str,
7273
user_id: str,
7374
attributes: Dict[str, Any],
74-
cmab_uuid: str
75-
) -> Optional[str]:
75+
cmab_uuid: str,
76+
timeout: Optional[float] = None
77+
) -> str:
7678
"""Fetch a decision from the CMAB prediction service.
7779
7880
Args:
7981
rule_id (str): The rule ID for the experiment.
8082
user_id (str): The user ID for the request.
8183
attributes (Dict[str, Any]): User attributes for the request.
8284
cmab_uuid (str): Unique identifier for the CMAB request.
85+
timeout (float): Maximum wait time for request to respond in seconds.
8386
8487
Returns:
85-
Optional[str]: The variation ID if successful, None otherwise.
88+
str: The variation ID.
8689
"""
8790
url = CMAB_PREDICTION_ENDPOINT % rule_id
91+
timeout = timeout or MAX_WAIT_TIME
8892
cmab_attributes = [
8993
{"id": key, "value": value, "type": "custom_attribute"}
9094
for key, value in attributes.items()
@@ -101,45 +105,50 @@ def fetch_decision(
101105

102106
try:
103107
if self.retry_config:
104-
variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config)
108+
variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config, timeout)
105109
else:
106-
variation_id = self._do_fetch(url, request_body)
110+
variation_id = self._do_fetch(url, request_body, timeout)
107111
return variation_id
108112

109113
except requests.RequestException as e:
110-
self.logger.error(Errors.CMAB_FETCH_FAILED.format(str(e)))
111-
return None
114+
error_message = Errors.CMAB_FETCH_FAILED.format(str(e))
115+
self.logger.error(error_message)
116+
raise CmabFetchError(error_message)
112117

113-
def _do_fetch(self, url: str, request_body: Dict[str, Any]) -> Optional[str]:
118+
def _do_fetch(self, url: str, request_body: Dict[str, Any], timeout: float) -> str:
114119
"""Perform a single fetch request to the CMAB prediction service.
115120
116121
Args:
117122
url (str): The endpoint URL.
118123
request_body (Dict[str, Any]): The request payload.
119-
124+
timeout (float): Maximum wait time for request to respond in seconds.
120125
Returns:
121-
Optional[str]: The variation ID if successful, None otherwise.
126+
str: The variation ID
122127
"""
123128
headers = {'Content-Type': 'application/json'}
124129
try:
125-
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=MAX_WAIT_TIME)
130+
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=timeout)
126131
except requests.exceptions.RequestException as e:
127-
self.logger.exception(Errors.CMAB_FETCH_FAILED.format(str(e)))
128-
return None
132+
error_message = Errors.CMAB_FETCH_FAILED.format(str(e))
133+
self.logger.error(error_message)
134+
raise CmabFetchError(error_message)
129135

130136
if not 200 <= response.status_code < 300:
131-
self.logger.exception(Errors.CMAB_FETCH_FAILED.format(str(response.status_code)))
132-
return None
137+
error_message = Errors.CMAB_FETCH_FAILED.format(str(response.status_code))
138+
self.logger.error(error_message)
139+
raise CmabFetchError(error_message)
133140

134141
try:
135142
body = response.json()
136143
except json.JSONDecodeError:
137-
self.logger.exception(Errors.INVALID_CMAB_FETCH_RESPONSE)
138-
return None
144+
error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
145+
self.logger.error(error_message)
146+
raise CmabInvalidResponseError(error_message)
139147

140148
if not self.validate_response(body):
141-
self.logger.exception(Errors.INVALID_CMAB_FETCH_RESPONSE)
142-
return None
149+
error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
150+
self.logger.error(error_message)
151+
raise CmabInvalidResponseError(error_message)
143152

144153
return str(body['predictions'][0]['variation_id'])
145154

@@ -165,27 +174,31 @@ def _do_fetch_with_retry(
165174
self,
166175
url: str,
167176
request_body: Dict[str, Any],
168-
retry_config: CmabRetryConfig
169-
) -> Optional[str]:
177+
retry_config: CmabRetryConfig,
178+
timeout: float
179+
) -> str:
170180
"""Perform a fetch request with retry logic.
171181
172182
Args:
173183
url (str): The endpoint URL.
174184
request_body (Dict[str, Any]): The request payload.
175185
retry_config (CmabRetryConfig): Configuration for retry logic.
176-
186+
timeout (float): Maximum wait time for request to respond in seconds.
177187
Returns:
178-
Optional[str]: The variation ID if successful, None otherwise.
188+
str: The variation ID
179189
"""
180190
backoff = retry_config.initial_backoff
181191
for attempt in range(retry_config.max_retries + 1):
182-
variation_id = self._do_fetch(url, request_body)
183-
if variation_id:
192+
try:
193+
variation_id = self._do_fetch(url, request_body, timeout)
184194
return variation_id
185-
if attempt < retry_config.max_retries:
186-
self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
187-
time.sleep(backoff)
188-
backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1),
189-
retry_config.max_backoff)
190-
self.logger.error(Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.'))
191-
return None
195+
except:
196+
if attempt < retry_config.max_retries:
197+
self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
198+
time.sleep(backoff)
199+
backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1),
200+
retry_config.max_backoff)
201+
202+
error_message = Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.')
203+
self.logger.error(error_message)
204+
raise CmabFetchError(error_message)

optimizely/exceptions.py

+18
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,21 @@ class OdpInvalidData(Exception):
8282
""" Raised when passing invalid ODP data. """
8383

8484
pass
85+
86+
87+
class CmabError(Exception):
88+
"""Base exception for CMAB client errors."""
89+
90+
pass
91+
92+
93+
class CmabFetchError(CmabError):
94+
"""Exception raised when CMAB fetch fails."""
95+
96+
pass
97+
98+
99+
class CmabInvalidResponseError(CmabError):
100+
"""Exception raised when CMAB response is invalid."""
101+
102+
pass

tests/test_cmab_client.py

+34-26
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from optimizely.cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
55
from requests.exceptions import RequestException
66
from optimizely.helpers.enums import Errors
7+
from optimizely.exceptions import CmabFetchError, CmabInvalidResponseError
78

89

910
class TestDefaultCmabClient_do_fetch(unittest.TestCase):
@@ -20,40 +21,52 @@ def test_do_fetch_success(self):
2021
}
2122
self.mock_http_client.post.return_value = mock_response
2223

23-
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
24+
result = self.client._do_fetch('http://fake-url', {'some': 'data'}, 1.0)
2425
self.assertEqual(result, 'abc123')
2526

2627
def test_do_fetch_http_exception(self):
2728
self.mock_http_client.post.side_effect = RequestException('Connection error')
28-
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
29-
self.assertIsNone(result)
30-
self.mock_logger.exception.assert_called_with(Errors.CMAB_FETCH_FAILED.format('Connection error'))
29+
30+
with self.assertRaises(CmabFetchError) as context:
31+
self.client._do_fetch('http://fake-url', {'some': 'data'}, 1.0)
32+
33+
self.mock_logger.error.assert_called_with(Errors.CMAB_FETCH_FAILED.format('Connection error'))
34+
self.assertIn('Connection error', str(context.exception))
3135

3236
def test_do_fetch_non_2xx_status(self):
3337
mock_response = MagicMock()
3438
mock_response.status_code = 500
3539
self.mock_http_client.post.return_value = mock_response
36-
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
37-
self.assertIsNone(result)
38-
self.mock_logger.exception.assert_called_with(Errors.CMAB_FETCH_FAILED.format(str(mock_response.status_code)))
40+
41+
with self.assertRaises(CmabFetchError) as context:
42+
self.client._do_fetch('http://fake-url', {'some': 'data'}, 1.0)
43+
44+
self.mock_logger.error.assert_called_with(Errors.CMAB_FETCH_FAILED.format(str(mock_response.status_code)))
45+
self.assertIn(str(mock_response.status_code), str(context.exception))
3946

4047
def test_do_fetch_invalid_json(self):
4148
mock_response = MagicMock()
4249
mock_response.status_code = 200
4350
mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
4451
self.mock_http_client.post.return_value = mock_response
45-
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
46-
self.assertIsNone(result)
47-
self.mock_logger.exception.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
52+
53+
with self.assertRaises(CmabInvalidResponseError) as context:
54+
self.client._do_fetch('http://fake-url', {'some': 'data'}, 1.0)
55+
56+
self.mock_logger.error.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
57+
self.assertIn(Errors.INVALID_CMAB_FETCH_RESPONSE, str(context.exception))
4858

4959
def test_do_fetch_invalid_response_structure(self):
5060
mock_response = MagicMock()
5161
mock_response.status_code = 200
5262
mock_response.json.return_value = {'no_predictions': []}
5363
self.mock_http_client.post.return_value = mock_response
54-
result = self.client._do_fetch('http://fake-url', {'some': 'data'})
55-
self.assertIsNone(result)
56-
self.mock_logger.exception.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
64+
65+
with self.assertRaises(CmabInvalidResponseError) as context:
66+
self.client._do_fetch('http://fake-url', {'some': 'data'}, 1.0)
67+
68+
self.mock_logger.error.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
69+
self.assertIn(Errors.INVALID_CMAB_FETCH_RESPONSE, str(context.exception))
5770

5871

5972
class TestDefaultCmabClientWithRetry(unittest.TestCase):
@@ -76,7 +89,7 @@ def test_do_fetch_with_retry_success_on_first_try(self, _):
7689
}
7790
self.mock_http_client.post.return_value = mock_response
7891

79-
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
92+
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config, 1.0)
8093
self.assertEqual(result, "abc123")
8194
self.assertEqual(self.mock_http_client.post.call_count, 1)
8295

@@ -97,7 +110,7 @@ def test_do_fetch_with_retry_success_on_retry(self, _):
97110
success_response
98111
]
99112

100-
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
113+
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config, 1.0)
101114
self.assertEqual(result, "xyz456")
102115
self.assertEqual(self.mock_http_client.post.call_count, 2)
103116
self.mock_logger.info.assert_called_with("Retrying CMAB request (attempt: 1) after 0.01 seconds...")
@@ -109,8 +122,9 @@ def test_do_fetch_with_retry_exhausts_all_attempts(self, _):
109122

110123
self.mock_http_client.post.return_value = failure_response
111124

112-
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
113-
self.assertIsNone(result)
125+
with self.assertRaises(CmabFetchError):
126+
self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config, 1.0)
127+
114128
self.assertEqual(self.mock_http_client.post.call_count, 3) # 1 original + 2 retries
115129
self.mock_logger.error.assert_called_with(
116130
Errors.CMAB_FETCH_FAILED.format("Exhausted all retries for CMAB request."))
@@ -124,7 +138,7 @@ def setUp(self):
124138
self.client = DefaultCmabClient(
125139
http_client=self.mock_http_client,
126140
logger=self.mock_logger,
127-
retry_config=self.retry_config
141+
retry_config=None
128142
)
129143
self.rule_id = 'test_rule'
130144
self.user_id = 'user123'
@@ -150,12 +164,6 @@ def test_fetch_decision_success_with_retry(self, mock_do_fetch_with_retry):
150164

151165
@patch.object(DefaultCmabClient, '_do_fetch', side_effect=RequestException("Network error"))
152166
def test_fetch_decision_request_exception(self, mock_do_fetch):
153-
result = self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
154-
self.assertIsNone(result)
167+
with self.assertRaises(CmabFetchError):
168+
self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
155169
self.mock_logger.error.assert_called_with(Errors.CMAB_FETCH_FAILED.format("Network error"))
156-
157-
@patch.object(DefaultCmabClient, '_do_fetch', return_value=None)
158-
def test_fetch_decision_invalid_response(self, mock_do_fetch):
159-
result = self.client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
160-
self.assertIsNone(result)
161-
self.mock_logger.error.assert_called_once()

0 commit comments

Comments
 (0)