Skip to content

Commit 194fa14

Browse files
authored
[FSSDK-12010] Expose cmab prediction endpoint in py sdk (#466)
* expose cmab prediction endpoint * fix typing requirement - exclude py version 3.11 and up * Add rpds-py restriction to core requirements for Python < 3.11
1 parent caba597 commit 194fa14

File tree

5 files changed

+101
-6
lines changed

5 files changed

+101
-6
lines changed

optimizely/cmab/cmab_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
DEFAULT_MAX_BACKOFF = 10 # in seconds
2626
DEFAULT_BACKOFF_MULTIPLIER = 2.0
2727
MAX_WAIT_TIME = 10.0
28+
DEFAULT_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/{}"
2829

2930

3031
class CmabRetryConfig:
@@ -52,17 +53,21 @@ class DefaultCmabClient:
5253
"""
5354
def __init__(self, http_client: Optional[requests.Session] = None,
5455
retry_config: Optional[CmabRetryConfig] = None,
55-
logger: Optional[_logging.Logger] = None):
56+
logger: Optional[_logging.Logger] = None,
57+
prediction_endpoint: Optional[str] = None):
5658
"""Initialize the CMAB client.
5759
5860
Args:
5961
http_client (Optional[requests.Session]): HTTP client for making requests.
6062
retry_config (Optional[CmabRetryConfig]): Configuration for retry logic.
6163
logger (Optional[_logging.Logger]): Logger for logging messages.
64+
prediction_endpoint (Optional[str]): Custom prediction endpoint URL template.
65+
Use {} as placeholder for rule_id.
6266
"""
6367
self.http_client = http_client or requests.Session()
6468
self.retry_config = retry_config
6569
self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger())
70+
self.prediction_endpoint = prediction_endpoint or DEFAULT_PREDICTION_ENDPOINT
6671

6772
def fetch_decision(
6873
self,
@@ -84,7 +89,7 @@ def fetch_decision(
8489
Returns:
8590
str: The variation ID.
8691
"""
87-
url = f"https://prediction.cmab.optimizely.com/predict/{rule_id}"
92+
url = self.prediction_endpoint.format(rule_id)
8893
cmab_attributes = [
8994
{"id": key, "value": value, "type": "custom_attribute"}
9095
for key, value in attributes.items()

optimizely/helpers/sdk_settings.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ def __init__(
3333
odp_event_manager: Optional[OdpEventManager] = None,
3434
odp_segment_request_timeout: Optional[int] = None,
3535
odp_event_request_timeout: Optional[int] = None,
36-
odp_event_flush_interval: Optional[int] = None
36+
odp_event_flush_interval: Optional[int] = None,
37+
cmab_prediction_endpoint: Optional[str] = None
3738
) -> None:
3839
"""
3940
Args:
@@ -52,6 +53,8 @@ def __init__(
5253
send successfully (optional).
5354
odp_event_request_timeout: Time to wait in seconds for send_odp_events request to send successfully.
5455
odp_event_flush_interval: Time to wait for events to accumulate before sending a batch in seconds (optional).
56+
cmab_prediction_endpoint: Custom CMAB prediction endpoint URL template (optional).
57+
Use {} as placeholder for rule_id. Defaults to production endpoint if not provided.
5558
"""
5659

5760
self.odp_disabled = odp_disabled
@@ -63,3 +66,4 @@ def __init__(
6366
self.fetch_segments_timeout = odp_segment_request_timeout
6467
self.odp_event_timeout = odp_event_request_timeout
6568
self.odp_flush_interval = odp_event_flush_interval
69+
self.cmab_prediction_endpoint = cmab_prediction_endpoint

optimizely/optimizely.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,15 @@ def __init__(
178178
if cmab_service:
179179
self.cmab_service = cmab_service
180180
else:
181+
# Get custom prediction endpoint from settings if provided
182+
cmab_prediction_endpoint = None
183+
if self.sdk_settings and self.sdk_settings.cmab_prediction_endpoint:
184+
cmab_prediction_endpoint = self.sdk_settings.cmab_prediction_endpoint
185+
181186
self.cmab_client = DefaultCmabClient(
182187
retry_config=CmabRetryConfig(),
183-
logger=self.logger
188+
logger=self.logger,
189+
prediction_endpoint=cmab_prediction_endpoint
184190
)
185191
self.cmab_cache: LRUCache[str, CmabCacheValue] = LRUCache(DEFAULT_CMAB_CACHE_SIZE,
186192
DEFAULT_CMAB_CACHE_TIMEOUT)

tests/test_cmab_client.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,81 @@ def test_fetch_decision_exhausts_all_retry_attempts(self, mock_sleep):
245245
self.mock_logger.error.assert_called_with(
246246
Errors.CMAB_FETCH_FAILED.format('Exhausted all retries for CMAB request.')
247247
)
248+
249+
def test_custom_prediction_endpoint(self):
250+
"""Test that custom prediction endpoint is used correctly."""
251+
custom_endpoint = "https://custom.endpoint.com/predict/{}"
252+
client = DefaultCmabClient(
253+
http_client=self.mock_http_client,
254+
logger=self.mock_logger,
255+
prediction_endpoint=custom_endpoint
256+
)
257+
258+
mock_response = MagicMock()
259+
mock_response.status_code = 200
260+
mock_response.json.return_value = {
261+
'predictions': [{'variation_id': 'abc123'}]
262+
}
263+
self.mock_http_client.post.return_value = mock_response
264+
265+
result = client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
266+
267+
self.assertEqual(result, 'abc123')
268+
expected_custom_url = custom_endpoint.format(self.rule_id)
269+
self.mock_http_client.post.assert_called_once_with(
270+
expected_custom_url,
271+
data=json.dumps(self.expected_body),
272+
headers=self.expected_headers,
273+
timeout=10.0
274+
)
275+
276+
def test_default_prediction_endpoint(self):
277+
"""Test that default prediction endpoint is used when none is provided."""
278+
client = DefaultCmabClient(
279+
http_client=self.mock_http_client,
280+
logger=self.mock_logger
281+
)
282+
283+
mock_response = MagicMock()
284+
mock_response.status_code = 200
285+
mock_response.json.return_value = {
286+
'predictions': [{'variation_id': 'def456'}]
287+
}
288+
self.mock_http_client.post.return_value = mock_response
289+
290+
result = client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
291+
292+
self.assertEqual(result, 'def456')
293+
# Should use the default production endpoint
294+
self.mock_http_client.post.assert_called_once_with(
295+
self.expected_url,
296+
data=json.dumps(self.expected_body),
297+
headers=self.expected_headers,
298+
timeout=10.0
299+
)
300+
301+
def test_empty_prediction_endpoint_uses_default(self):
302+
"""Test that empty string prediction endpoint falls back to default."""
303+
client = DefaultCmabClient(
304+
http_client=self.mock_http_client,
305+
logger=self.mock_logger,
306+
prediction_endpoint=""
307+
)
308+
309+
mock_response = MagicMock()
310+
mock_response.status_code = 200
311+
mock_response.json.return_value = {
312+
'predictions': [{'variation_id': 'ghi789'}]
313+
}
314+
self.mock_http_client.post.return_value = mock_response
315+
316+
result = client.fetch_decision(self.rule_id, self.user_id, self.attributes, self.cmab_uuid)
317+
318+
self.assertEqual(result, 'ghi789')
319+
# Should use the default production endpoint when empty string is provided
320+
self.mock_http_client.post.assert_called_once_with(
321+
self.expected_url,
322+
data=json.dumps(self.expected_body),
323+
headers=self.expected_headers,
324+
timeout=10.0
325+
)

tests/test_config_manager.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,8 +517,10 @@ def test_fetch_datafile__exception_polling_thread_failed(self, _):
517517
log_messages = [args[0] for args, _ in mock_logger.error.call_args_list]
518518
for message in log_messages:
519519
print(message)
520-
if "Thread for background datafile polling failed. " \
521-
"Error: timestamp too large to convert to C PyTime_t" not in message:
520+
# Check for key parts of the error message (version-agnostic for Python 3.11+)
521+
if not ("Thread for background datafile polling failed" in message and
522+
"timestamp too large to convert to C" in message and
523+
"PyTime_t" in message):
522524
assert False
523525

524526
def test_is_running(self, _):

0 commit comments

Comments
 (0)