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.
113import json
214import time
315import requests
416import 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
719from 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
1325DEFAULT_MAX_RETRIES = 3
1426DEFAULT_INITIAL_BACKOFF = 0.1 # in seconds (100 ms)
15- DEFAULT_MAX_BACKOFF = 10 # in seconds
27+ DEFAULT_MAX_BACKOFF = 10 # in seconds
1628DEFAULT_BACKOFF_MULTIPLIER = 2.0
1729MAX_WAIT_TIME = 10.0
1830
31+
1932class 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+
3250class 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
0 commit comments