Skip to content

Commit 4edd3b9

Browse files
Implement CMAB client with retry logic for fetching predictions
1 parent f8da261 commit 4edd3b9

File tree

1 file changed

+119
-0
lines changed

1 file changed

+119
-0
lines changed

optimizely/cmab/cmab_client.py

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import json
2+
import time
3+
import requests
4+
import math
5+
from typing import Dict, Any, Optional, List
6+
from optimizely import logger as _logging
7+
from optimizely.helpers.enums import Errors
8+
9+
# CMAB_PREDICTION_ENDPOINT is the endpoint for CMAB predictions
10+
CMAB_PREDICTION_ENDPOINT = "https://prediction.cmab.optimizely.com/predict/%s"
11+
12+
# Default constants for CMAB requests
13+
DEFAULT_MAX_RETRIES = 3
14+
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
15+
DEFAULT_MAX_BACKOFF = 10 # in seconds
16+
DEFAULT_BACKOFF_MULTIPLIER = 2.0
17+
MAX_WAIT_TIME = 10.0
18+
19+
class CmabRetryConfig:
20+
def __init__(
21+
self,
22+
max_retries: int = DEFAULT_MAX_RETRIES,
23+
initial_backoff: float = DEFAULT_INITIAL_BACKOFF,
24+
max_backoff: float = DEFAULT_MAX_BACKOFF,
25+
backoff_multiplier: float = DEFAULT_BACKOFF_MULTIPLIER,
26+
):
27+
self.max_retries = max_retries
28+
self.initial_backoff = initial_backoff
29+
self.max_backoff = max_backoff
30+
self.backoff_multiplier = backoff_multiplier
31+
32+
class DefaultCmabClient:
33+
def __init__(self, http_client: Optional[requests.Session] = None,
34+
retry_config: Optional[CmabRetryConfig] = None,
35+
logger: Optional[_logging.Logger] = None):
36+
self.http_client = http_client or requests.Session()
37+
self.retry_config = retry_config
38+
self.logger = _logging.adapt_logger(logger or _logging.NoOpLogger())
39+
40+
def fetch_decision(
41+
self,
42+
rule_id: str,
43+
user_id: str,
44+
attributes: Dict[str, Any],
45+
cmab_uuid: str
46+
) -> Optional[str]:
47+
48+
url = CMAB_PREDICTION_ENDPOINT % rule_id
49+
cmab_attributes = [
50+
{"id": key, "value": value, "type": "custom_attribute"}
51+
for key, value in attributes.items()
52+
]
53+
54+
request_body = {
55+
"instances": [{
56+
"visitorId": user_id,
57+
"experimentId": rule_id,
58+
"attributes": cmab_attributes,
59+
"cmabUUID": cmab_uuid,
60+
}]
61+
}
62+
63+
try:
64+
if self.retry_config:
65+
variation_id = self._do_fetch_with_retry(url, request_body, self.retry_config)
66+
else:
67+
variation_id = self._do_fetch(url, request_body)
68+
return variation_id
69+
70+
except requests.RequestException as e:
71+
self.logger.error(f"Error fetching CMAB decision: {e}")
72+
pass
73+
74+
def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
75+
headers = {'Content-Type': 'application/json'}
76+
try:
77+
response = self.http_client.post(url, data=json.dumps(request_body), headers=headers, timeout=MAX_WAIT_TIME)
78+
except requests.exceptions.RequestException as e:
79+
self.logger.exception(str(e))
80+
return None
81+
82+
if not 200 <= response.status_code < 300:
83+
self.logger.exception(f'CMAB Request failed with status code: {response.status_code}')
84+
return None
85+
86+
try:
87+
body = response.json()
88+
except json.JSONDecodeError as e:
89+
self.logger.exception(str(e))
90+
return None
91+
92+
if not self.validate_response(body):
93+
self.logger.exception('Invalid response')
94+
return None
95+
96+
return str(body['predictions'][0]['variation_id'])
97+
98+
def validate_response(self, body: dict) -> bool:
99+
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]
106+
)
107+
108+
def _do_fetch_with_retry(self, url: str, request_body: dict, retry_config: CmabRetryConfig) -> Optional[str]:
109+
backoff = retry_config.initial_backoff
110+
for attempt in range(retry_config.max_retries + 1):
111+
variation_id = self._do_fetch(url, request_body)
112+
if variation_id:
113+
return variation_id
114+
if attempt < retry_config.max_retries:
115+
self.logger.info(f"Retrying CMAB request (attempt: {attempt + 1}) after {backoff} seconds...")
116+
time.sleep(backoff)
117+
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.")
119+
return None

0 commit comments

Comments
 (0)