diff --git a/src/amplitude_experiment/client.py b/src/amplitude_experiment/client.py index ee6b421..32e237c 100644 --- a/src/amplitude_experiment/client.py +++ b/src/amplitude_experiment/client.py @@ -1,35 +1,78 @@ +import time +from time import sleep from .config import Config from .version import __version__ from .variant import Variant +from .user import User import http.client import json +import logging class Client: + """Main client for fetching variant data.""" + def __init__(self, api_key, config=None): + """ + Creates a new Experiment Client instance. + Parameters: + api_key (str): The environment API Key + config (Config): Config Object + + Returns: + Experiment Client instance. + """ + if not api_key: + raise ValueError("Experiment API key is empty") self.api_key = api_key self.config = config or Config() + self.logger = logging.getLogger("Amplitude") + self.logger.addHandler(logging.StreamHandler()) + if self.config.debug: + self.logger.setLevel(logging.DEBUG) + + def fetch(self, user: User): + """ + Fetch all variants for a user synchronous.This method will automatically retry if configured. + Parameters: + user (User): The Experiment User - def fetch(self, user): + Returns: + Variants Dictionary. + """ try: return self.fetch_internal(user) - except: - print("[Experiment] Failed to fetch variants") + except Exception as e: + self.logger.error(f"[Experiment] Failed to fetch variants: {e}") return {} def fetch_internal(self, user): + self.logger.debug(f"[Experiment] Fetching variants for user: {user}") try: return self.do_fetch(user, self.config.fetch_timeout_millis) - except: - print("Experiment] Fetch failed") + except Exception as e: + self.logger.error(f"Experiment] Fetch failed: {e}") return self.retry_fetch(user) def retry_fetch(self, user): if self.config.fetch_retries == 0: return {} - pass + self.logger.debug("[Experiment] Retrying fetch") + err = None + delay_millis = self.config.fetch_retry_backoff_min_millis + for i in range(self.config.fetch_retries): + sleep(delay_millis / 1000.0) + try: + return self.do_fetch(user, self.config.fetch_timeout_millis) + except Exception as e: + self.logger.error(f"[Experiment] Retry failed: {e}") + err = e + delay_millis = min(delay_millis * self.config.fetch_retry_backoff_scalar, + self.config.fetch_retry_backoff_max_millis) + raise err def do_fetch(self, user, timeout_millis): + start = time.time() user_context = self.add_context(user) headers = { 'Authorization': f"Api-Key {self.api_key}", @@ -40,10 +83,17 @@ def do_fetch(self, user, timeout_millis): conn = Connection(host) conn.connect() body = user_context.to_json().encode('utf8') + if len(body) > 8000: + self.logger.warning(f"[Experiment] encoded user object length ${len(body)} " + f"cannot be cached by CDN; must be < 8KB") + self.logger.debug(f"[Experiment] Fetch variants for user: {str(user_context)}") conn.request('POST', '/sdk/vardata', body, headers) response = conn.getresponse() + elapsed = '%.3f' % ((time.time() - start) * 1000) + self.logger.debug(f"[Experiment] Fetch complete in {elapsed} ms") json_response = json.loads(response.read().decode("utf8")) variants = self.parse_json_variants(json_response) + self.logger.debug(f"[Experiment] Fetched variants: {json.dumps(variants, default=str)}") conn.close() return variants diff --git a/src/amplitude_experiment/config.py b/src/amplitude_experiment/config.py index 46bb319..6b6e729 100644 --- a/src/amplitude_experiment/config.py +++ b/src/amplitude_experiment/config.py @@ -1,4 +1,6 @@ class Config: + """Experiment Client Configuration""" + DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com' def __init__(self, debug=False, @@ -9,6 +11,24 @@ def __init__(self, debug=False, fetch_retry_backoff_max_millis=10000, fetch_retry_backoff_scalar=1.5, fetch_retry_timeout_millis=10000): + """ + Initialize a config + Parameters: + debug (str): Set to true to log some extra information to the console. + server_url (str): The server endpoint from which to request variants. + fetch_timeout_millis (int): The request timeout, in milliseconds, used when fetching variants + triggered by calling start() or setUser(). + fetch_retries (int): The number of retries to attempt before failing. + fetch_retry_backoff_min_millis (int): Retry backoff minimum (starting backoff delay) in milliseconds. + The minimum backoff is scaled by `fetch_retry_backoff_scalar` after each retry failure. + fetch_retry_backoff_max_millis (int): Retry backoff maximum in milliseconds. If the scaled backoff is + greater than the max, the max is used for all subsequent retries. + fetch_retry_backoff_scalar (float): Scales the minimum backoff exponentially. + fetch_retry_timeout_millis (int): The request timeout for retrying fetch requests. + + Returns: + The config object + """ self.debug = debug self.server_url = server_url self.fetch_timeout_millis = fetch_timeout_millis diff --git a/src/amplitude_experiment/user.py b/src/amplitude_experiment/user.py index ad1410b..ab53249 100644 --- a/src/amplitude_experiment/user.py +++ b/src/amplitude_experiment/user.py @@ -2,9 +2,36 @@ class User: + """ + Defines a user context for evaluation. `device_id` and `user_id` are used for identity resolution. + All other predefined fields and user properties are used for rule based user targeting. + """ def __init__(self, device_id=None, user_id=None, country=None, city=None, region=None, dma=None, language=None, platform=None, version=None, os=None, device_manufacturer=None, device_brand=None, device_model=None, carrier=None, library=None, user_properties=None): + """ + Initialize User instance + Parameters: + device_id (str): Device ID for associating with an identity in Amplitude + user_id (str): User ID for associating with an identity in Amplitude + country (str): Predefined field, must be manually provided + city (str): Predefined field, must be manually provided + region (str): Predefined field, must be manually provided + dma (str): Predefined field, must be manually provided + language (str): Predefined field, must be manually provided + platform (str): Predefined field, must be manually provided + version (str): Predefined field, must be manually provided + os (str): Predefined field, must be manually provided + device_manufacturer (str): Predefined field, must be manually provided + device_brand (str): Predefined field, must be manually provided + device_model (str): Predefined field, must be manually provided + carrier (str): Predefined field, must be manually provided + library (str): Predefined field, must be manually provided + user_properties (dict): Custom user properties + + Returns: + User object + """ self.device_id = device_id self.user_id = user_id self.country = country @@ -23,4 +50,9 @@ def __init__(self, device_id=None, user_id=None, country=None, city=None, region self.user_properties = user_properties def to_json(self): + """Return user information as JSON string.""" return json.dumps(self, default=lambda o: o.__dict__) + + def __str__(self): + """Return user as string""" + return self.to_json() diff --git a/src/amplitude_experiment/variant.py b/src/amplitude_experiment/variant.py index 2c6821e..6861a07 100644 --- a/src/amplitude_experiment/variant.py +++ b/src/amplitude_experiment/variant.py @@ -1,8 +1,30 @@ class Variant: + """Variant Class""" - def __init__(self, value, payload): + def __init__(self, value: str, payload=None): + """ + Initialize a Variant + Parameters: + value (str): The value of the variant determined by the flag configuration. + payload (Any): The attached payload, if any. + + Returns: + Experiment User context containing a device_id and user_id (if available) + """ self.value = value self.payload = payload - def __eq__(self, obj): + def __eq__(self, obj) -> bool: + """ + Determine if current variant equal other variant + Parameters: + obj (Variant): The variant to compare with + + Returns: + True if two variant equals, otherwise False + """ return self.value == obj.value and self.payload == obj.payload + + def __str__(self): + """Return Variant as string""" + return f"value: {self.value}, payload: {self.payload}" diff --git a/tests/client_test.py b/tests/client_test.py index dc1f954..fcb3d75 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -1,6 +1,6 @@ import unittest -from src.amplitude_experiment import Client, Variant, User +from src.amplitude_experiment import Client, Variant, User, Config API_KEY = 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3' SERVER_URL = 'https://api.lab.amplitude.com/sdk/vardata' @@ -8,6 +8,9 @@ class ClientTestCase(unittest.TestCase): + def test_initialize_raise_error(self): + self.assertRaises(ValueError, Client, "") + def test_fetch(self): client = Client(API_KEY) expected_variant = Variant('on', 'payload')