Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions src/amplitude_experiment/client.py
Original file line number Diff line number Diff line change
@@ -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}",
Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions src/amplitude_experiment/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Config:
"""Experiment Client Configuration"""

DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com'

def __init__(self, debug=False,
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/amplitude_experiment/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
26 changes: 24 additions & 2 deletions src/amplitude_experiment/variant.py
Original file line number Diff line number Diff line change
@@ -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}"
5 changes: 4 additions & 1 deletion tests/client_test.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
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'


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')
Expand Down