Skip to content

Commit 046d457

Browse files
[FSSDK-11148] update: Implement CMAB Client (#453)
* Implement CMAB client with retry logic for fetching predictions * Enhance CMAB client error handling and logging; add unit tests for fetch methods * Refactor CMAB client: enhance docstrings for classes and methods, improve formatting, and clean up imports * Add custom exceptions for CMAB client errors and enhance error handling in fetch methods * Update fetch_decision method to set default timeout value to 10 seconds * replace constant endpoint with formatted string in fetch_decision method * chore: trigger CI * refactor: streamline fetch_decision method and enhance test cases for improved clarity and functionality
1 parent 72048b6 commit 046d457

File tree

4 files changed

+448
-0
lines changed

4 files changed

+448
-0
lines changed

optimizely/cmab/cmab_client.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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.
13+
import json
14+
import time
15+
import requests
16+
import math
17+
from typing import Dict, Any, Optional
18+
from optimizely import logger as _logging
19+
from optimizely.helpers.enums import Errors
20+
from optimizely.exceptions import CmabFetchError, CmabInvalidResponseError
21+
22+
# Default constants for CMAB requests
23+
DEFAULT_MAX_RETRIES = 3
24+
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
25+
DEFAULT_MAX_BACKOFF = 10 # in seconds
26+
DEFAULT_BACKOFF_MULTIPLIER = 2.0
27+
MAX_WAIT_TIME = 10.0
28+
29+
30+
class CmabRetryConfig:
31+
"""Configuration for retrying CMAB requests.
32+
33+
Contains parameters for maximum retries, backoff intervals, and multipliers.
34+
"""
35+
def __init__(
36+
self,
37+
max_retries: int = DEFAULT_MAX_RETRIES,
38+
initial_backoff: float = DEFAULT_INITIAL_BACKOFF,
39+
max_backoff: float = DEFAULT_MAX_BACKOFF,
40+
backoff_multiplier: float = DEFAULT_BACKOFF_MULTIPLIER,
41+
):
42+
self.max_retries = max_retries
43+
self.initial_backoff = initial_backoff
44+
self.max_backoff = max_backoff
45+
self.backoff_multiplier = backoff_multiplier
46+
47+
48+
class DefaultCmabClient:
49+
"""Client for interacting with the CMAB service.
50+
51+
Provides methods to fetch decisions with optional retry logic.
52+
"""
53+
def __init__(self, http_client: Optional[requests.Session] = None,
54+
retry_config: Optional[CmabRetryConfig] = None,
55+
logger: Optional[_logging.Logger] = None):
56+
"""Initialize the CMAB client.
57+
58+
Args:
59+
http_client (Optional[requests.Session]): HTTP client for making requests.
60+
retry_config (Optional[CmabRetryConfig]): Configuration for retry logic.
61+
logger (Optional[_logging.Logger]): Logger for logging messages.
62+
"""
63+
self.http_client = http_client or requests.Session()
64+
self.retry_config = retry_config
65+
self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger())
66+
67+
def fetch_decision(
68+
self,
69+
rule_id: str,
70+
user_id: str,
71+
attributes: Dict[str, Any],
72+
cmab_uuid: str,
73+
timeout: float = MAX_WAIT_TIME
74+
) -> str:
75+
"""Fetch a decision from the CMAB prediction service.
76+
77+
Args:
78+
rule_id (str): The rule ID for the experiment.
79+
user_id (str): The user ID for the request.
80+
attributes (Dict[str, Any]): User attributes for the request.
81+
cmab_uuid (str): Unique identifier for the CMAB request.
82+
timeout (float): Maximum wait time for request to respond in seconds. Defaults to 10 seconds.
83+
84+
Returns:
85+
str: The variation ID.
86+
"""
87+
url = f"https://prediction.cmab.optimizely.com/predict/{rule_id}"
88+
cmab_attributes = [
89+
{"id": key, "value": value, "type": "custom_attribute"}
90+
for key, value in attributes.items()
91+
]
92+
93+
request_body = {
94+
"instances": [{
95+
"visitorId": user_id,
96+
"experimentId": rule_id,
97+
"attributes": cmab_attributes,
98+
"cmabUUID": cmab_uuid,
99+
}]
100+
}
101+
if self.retry_config:
102+
variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config, timeout)
103+
else:
104+
variation_id = self._do_fetch(url, request_body, timeout)
105+
return variation_id
106+
107+
def _do_fetch(self, url: str, request_body: Dict[str, Any], timeout: float) -> str:
108+
"""Perform a single fetch request to the CMAB prediction service.
109+
110+
Args:
111+
url (str): The endpoint URL.
112+
request_body (Dict[str, Any]): The request payload.
113+
timeout (float): Maximum wait time for request to respond in seconds.
114+
Returns:
115+
str: The variation ID
116+
"""
117+
headers = {'Content-Type': 'application/json'}
118+
try:
119+
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=timeout)
120+
except requests.exceptions.RequestException as e:
121+
error_message = Errors.CMAB_FETCH_FAILED.format(str(e))
122+
self.logger.error(error_message)
123+
raise CmabFetchError(error_message)
124+
125+
if not 200 <= response.status_code < 300:
126+
error_message = Errors.CMAB_FETCH_FAILED.format(str(response.status_code))
127+
self.logger.error(error_message)
128+
raise CmabFetchError(error_message)
129+
130+
try:
131+
body = response.json()
132+
except json.JSONDecodeError:
133+
error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
134+
self.logger.error(error_message)
135+
raise CmabInvalidResponseError(error_message)
136+
137+
if not self.validate_response(body):
138+
error_message = Errors.INVALID_CMAB_FETCH_RESPONSE
139+
self.logger.error(error_message)
140+
raise CmabInvalidResponseError(error_message)
141+
142+
return str(body['predictions'][0]['variation_id'])
143+
144+
def validate_response(self, body: Dict[str, Any]) -> bool:
145+
"""Validate the response structure from the CMAB service.
146+
147+
Args:
148+
body (Dict[str, Any]): The response body to validate.
149+
150+
Returns:
151+
bool: True if the response is valid, False otherwise.
152+
"""
153+
return (
154+
isinstance(body, dict) and
155+
'predictions' in body and
156+
isinstance(body['predictions'], list) and
157+
len(body['predictions']) > 0 and
158+
isinstance(body['predictions'][0], dict) and
159+
"variation_id" in body["predictions"][0]
160+
)
161+
162+
def _do_fetch_with_retry(
163+
self,
164+
url: str,
165+
request_body: Dict[str, Any],
166+
retry_config: CmabRetryConfig,
167+
timeout: float
168+
) -> str:
169+
"""Perform a fetch request with retry logic.
170+
171+
Args:
172+
url (str): The endpoint URL.
173+
request_body (Dict[str, Any]): The request payload.
174+
retry_config (CmabRetryConfig): Configuration for retry logic.
175+
timeout (float): Maximum wait time for request to respond in seconds.
176+
Returns:
177+
str: The variation ID
178+
"""
179+
backoff = retry_config.initial_backoff
180+
for attempt in range(retry_config.max_retries + 1):
181+
try:
182+
variation_id = self._do_fetch(url, request_body, timeout)
183+
return variation_id
184+
except:
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+
191+
error_message = Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.')
192+
self.logger.error(error_message)
193+
raise CmabFetchError(error_message)

optimizely/exceptions.py

Lines changed: 18 additions & 0 deletions
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

optimizely/helpers/enums.py

Lines changed: 2 additions & 0 deletions
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:

0 commit comments

Comments
 (0)