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.
1
13
import json
2
14
import time
3
15
import requests
4
16
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
7
19
from optimizely .helpers .enums import Errors
8
20
9
21
# 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"
11
23
12
24
# Default constants for CMAB requests
13
25
DEFAULT_MAX_RETRIES = 3
14
26
DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
15
- DEFAULT_MAX_BACKOFF = 10 # in seconds
27
+ DEFAULT_MAX_BACKOFF = 10 # in seconds
16
28
DEFAULT_BACKOFF_MULTIPLIER = 2.0
17
29
MAX_WAIT_TIME = 10.0
18
30
31
+
19
32
class CmabRetryConfig :
33
+ """Configuration for retrying CMAB requests.
34
+
35
+ Contains parameters for maximum retries, backoff intervals, and multipliers.
36
+ """
20
37
def __init__ (
21
38
self ,
22
39
max_retries : int = DEFAULT_MAX_RETRIES ,
@@ -29,22 +46,44 @@ def __init__(
29
46
self .max_backoff = max_backoff
30
47
self .backoff_multiplier = backoff_multiplier
31
48
49
+
32
50
class DefaultCmabClient :
51
+ """Client for interacting with the CMAB service.
52
+
53
+ Provides methods to fetch decisions with optional retry logic.
54
+ """
33
55
def __init__ (self , http_client : Optional [requests .Session ] = None ,
34
56
retry_config : Optional [CmabRetryConfig ] = None ,
35
57
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
+ """
36
65
self .http_client = http_client or requests .Session ()
37
66
self .retry_config = retry_config
38
67
self .logger = _logging .adapt_logger (logger or _logging .NoOpLogger ())
39
-
68
+
40
69
def fetch_decision (
41
70
self ,
42
71
rule_id : str ,
43
72
user_id : str ,
44
73
attributes : Dict [str , Any ],
45
74
cmab_uuid : str
46
75
) -> 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.
47
83
84
+ Returns:
85
+ Optional[str]: The variation ID if successful, None otherwise.
86
+ """
48
87
url = CMAB_PREDICTION_ENDPOINT % rule_id
49
88
cmab_attributes = [
50
89
{"id" : key , "value" : value , "type" : "custom_attribute" }
@@ -59,7 +98,7 @@ def fetch_decision(
59
98
"cmabUUID" : cmab_uuid ,
60
99
}]
61
100
}
62
-
101
+
63
102
try :
64
103
if self .retry_config :
65
104
variation_id = self ._do_fetch_with_retry (url , request_body , self .retry_config )
@@ -71,7 +110,16 @@ def fetch_decision(
71
110
self .logger .error (Errors .CMAB_FETCH_FAILED .format (str (e )))
72
111
return None
73
112
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
+ """
75
123
headers = {'Content-Type' : 'application/json' }
76
124
try :
77
125
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]:
85
133
86
134
try :
87
135
body = response .json ()
88
- except json .JSONDecodeError as e :
136
+ except json .JSONDecodeError :
89
137
self .logger .exception (Errors .INVALID_CMAB_FETCH_RESPONSE )
90
138
return None
91
139
@@ -94,18 +142,41 @@ def _do_fetch(self, url: str, request_body: str) -> Optional[str]:
94
142
return None
95
143
96
144
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
+ """
99
155
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 ]
106
162
)
107
163
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
+ """
109
180
backoff = retry_config .initial_backoff
110
181
for attempt in range (retry_config .max_retries + 1 ):
111
182
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
114
185
if attempt < retry_config .max_retries :
115
186
self .logger .info (f"Retrying CMAB request (attempt: { attempt + 1 } ) after { backoff } seconds..." )
116
187
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 )
118
190
self .logger .error (Errors .CMAB_FETCH_FAILED .format ('Exhausted all retries for CMAB request.' ))
119
191
return None
0 commit comments