Skip to content

Commit 5e6bbde

Browse files
Refactor CMAB client: enhance docstrings for classes and methods, improve formatting, and clean up imports
1 parent 34d0385 commit 5e6bbde

File tree

2 files changed

+96
-20
lines changed

2 files changed

+96
-20
lines changed

optimizely/cmab/cmab_client.py

+90-18
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,39 @@
1+
# Copyright 2025 Optimizely
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
113
import json
214
import time
315
import requests
416
import math
5-
from typing import Dict, Any, Optional, List
6-
from optimizely import logger as _logging
17+
from typing import Dict, Any, Optional
18+
from optimizely import logger as _logging
719
from optimizely.helpers.enums import Errors
820

921
# CMAB_PREDICTION_ENDPOINT is the endpoint for CMAB predictions
10-
CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"
22+
CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"
1123

1224
# Default constants for CMAB requests
1325
DEFAULT_MAX_RETRIES = 3
1426
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
15-
DEFAULT_MAX_BACKOFF = 10 # in seconds
27+
DEFAULT_MAX_BACKOFF = 10 # in seconds
1628
DEFAULT_BACKOFF_MULTIPLIER = 2.0
1729
MAX_WAIT_TIME = 10.0
1830

31+
1932
class CmabRetryConfig:
33+
"""Configuration for retrying CMAB requests.
34+
35+
Contains parameters for maximum retries, backoff intervals, and multipliers.
36+
"""
2037
def __init__(
2138
self,
2239
max_retries: int = DEFAULT_MAX_RETRIES,
@@ -29,22 +46,44 @@ def __init__(
2946
self.max_backoff = max_backoff
3047
self.backoff_multiplier = backoff_multiplier
3148

49+
3250
class DefaultCmabClient:
51+
"""Client for interacting with the CMAB service.
52+
53+
Provides methods to fetch decisions with optional retry logic.
54+
"""
3355
def __init__(self, http_client: Optional[requests.Session] = None,
3456
retry_config: Optional[CmabRetryConfig] = None,
3557
logger: Optional[_logging.Logger] = None):
58+
"""Initialize the CMAB client.
59+
60+
Args:
61+
http_client (Optional[requests.Session]): HTTP client for making requests.
62+
retry_config (Optional[CmabRetryConfig]): Configuration for retry logic.
63+
logger (Optional[_logging.Logger]): Logger for logging messages.
64+
"""
3665
self.http_client = http_client or requests.Session()
3766
self.retry_config = retry_config
3867
self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger())
39-
68+
4069
def fetch_decision(
4170
self,
4271
rule_id: str,
4372
user_id: str,
4473
attributes: Dict[str, Any],
4574
cmab_uuid: str
4675
) -> Optional[str]:
76+
"""Fetch a decision from the CMAB prediction service.
77+
78+
Args:
79+
rule_id (str): The rule ID for the experiment.
80+
user_id (str): The user ID for the request.
81+
attributes (Dict[str, Any]): User attributes for the request.
82+
cmab_uuid (str): Unique identifier for the CMAB request.
4783
84+
Returns:
85+
Optional[str]: The variation ID if successful, None otherwise.
86+
"""
4887
url = CMAB_PREDICTION_ENDPOINT % rule_id
4988
cmab_attributes = [
5089
{"id": key, "value": value, "type": "custom_attribute"}
@@ -59,7 +98,7 @@ def fetch_decision(
5998
"cmabUUID": cmab_uuid,
6099
}]
61100
}
62-
101+
63102
try:
64103
if self.retry_config:
65104
variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config)
@@ -71,7 +110,16 @@ def fetch_decision(
71110
self.logger.error(Errors.CMAB_FETCH_FAILED.format(str(e)))
72111
return None
73112

74-
def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
113+
def _do_fetch(self, url: str, request_body: Dict[str, Any]) -> Optional[str]:
114+
"""Perform a single fetch request to the CMAB prediction service.
115+
116+
Args:
117+
url (str): The endpoint URL.
118+
request_body (Dict[str, Any]): The request payload.
119+
120+
Returns:
121+
Optional[str]: The variation ID if successful, None otherwise.
122+
"""
75123
headers = {'Content-Type': 'application/json'}
76124
try:
77125
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=MAX_WAIT_TIME)
@@ -85,7 +133,7 @@ def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
85133

86134
try:
87135
body = response.json()
88-
except json.JSONDecodeError as e:
136+
except json.JSONDecodeError:
89137
self.logger.exception(Errors.INVALID_CMAB_FETCH_RESPONSE)
90138
return None
91139

@@ -94,18 +142,41 @@ def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
94142
return None
95143

96144
return str(body['predictions'][0]['variation_id'])
97-
98-
def validate_response(self, body: dict) -> bool:
145+
146+
def validate_response(self, body: Dict[str, Any]) -> bool:
147+
"""Validate the response structure from the CMAB service.
148+
149+
Args:
150+
body (Dict[str, Any]): The response body to validate.
151+
152+
Returns:
153+
bool: True if the response is valid, False otherwise.
154+
"""
99155
return (
100-
isinstance(body, dict)
101-
and 'predictions' in body
102-
and isinstance(body['predictions'], list)
103-
and len(body['predictions']) > 0
104-
and isinstance(body['predictions'][0], dict)
105-
and "variation_id" in body["predictions"][0]
156+
isinstance(body, dict) and
157+
'predictions' in body and
158+
isinstance(body['predictions'], list) and
159+
len(body['predictions']) > 0 and
160+
isinstance(body['predictions'][0], dict) and
161+
"variation_id" in body["predictions"][0]
106162
)
107163

108-
def _do_fetch_with_retry(self, url: str, request_body: dict, retry_config: CmabRetryConfig) -> Optional[str]:
164+
def _do_fetch_with_retry(
165+
self,
166+
url: str,
167+
request_body: Dict[str, Any],
168+
retry_config: CmabRetryConfig
169+
) -> Optional[str]:
170+
"""Perform a fetch request with retry logic.
171+
172+
Args:
173+
url (str): The endpoint URL.
174+
request_body (Dict[str, Any]): The request payload.
175+
retry_config (CmabRetryConfig): Configuration for retry logic.
176+
177+
Returns:
178+
Optional[str]: The variation ID if successful, None otherwise.
179+
"""
109180
backoff = retry_config.initial_backoff
110181
for attempt in range(retry_config.max_retries + 1):
111182
variation_id = self._do_fetch(url, request_body)
@@ -114,6 +185,7 @@ def _do_fetch_with_retry(self, url: str, request_body: dict, retry_config: CmabR
114185
if attempt < retry_config.max_retries:
115186
self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
116187
time.sleep(backoff)
117-
backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1), retry_config.max_backoff)
188+
backoff = min(backoff * math.pow(retry_config.backoff_multiplier, attempt + 1),
189+
retry_config.max_backoff)
118190
self.logger.error(Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.'))
119191
return None

tests/test_cmab_client.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import unittest
22
import json
3-
from unittest.mock import MagicMock, patch, call
3+
from unittest.mock import MagicMock, patch
44
from optimizely.cmab.cmab_client import DefaultCmabClient, CmabRetryConfig
55
from requests.exceptions import RequestException
66
from optimizely.helpers.enums import Errors
77

8+
89
class TestDefaultCmabClient_do_fetch(unittest.TestCase):
910
def setUp(self):
1011
self.mock_http_client = MagicMock()
@@ -54,6 +55,7 @@ def test_do_fetch_invalid_response_structure(self):
5455
self.assertIsNone(result)
5556
self.mock_logger.exception.assert_called_with(Errors.INVALID_CMAB_FETCH_RESPONSE)
5657

58+
5759
class TestDefaultCmabClientWithRetry(unittest.TestCase):
5860
def setUp(self):
5961
self.mock_http_client = MagicMock()
@@ -110,7 +112,9 @@ def test_do_fetch_with_retry_exhausts_all_attempts(self, _):
110112
result = self.client._do_fetch_with_retry("http://fake-url", {}, self.retry_config)
111113
self.assertIsNone(result)
112114
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."))
115+
self.mock_logger.error.assert_called_with(
116+
Errors.CMAB_FETCH_FAILED.format("Exhausted all retries for CMAB request."))
117+
114118

115119
class TestDefaultCmabClientFetchDecision(unittest.TestCase):
116120
def setUp(self):

0 commit comments

Comments
 (0)