Skip to content

Commit 08f314e

Browse files
Merging config manager changes into master (#184)
1 parent 440c2d2 commit 08f314e

15 files changed

+1155
-262
lines changed

optimizely/config_manager.py

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
# Copyright 2019, 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.
13+
14+
import abc
15+
import requests
16+
import threading
17+
import time
18+
from requests import codes as http_status_codes
19+
from requests import exceptions as requests_exceptions
20+
21+
from . import exceptions as optimizely_exceptions
22+
from . import logger as optimizely_logger
23+
from . import project_config
24+
from .error_handler import NoOpErrorHandler
25+
from .notification_center import NotificationCenter
26+
from .helpers import enums
27+
from .helpers import validator
28+
29+
ABC = abc.ABCMeta('ABC', (object,), {'__slots__': ()})
30+
31+
32+
class BaseConfigManager(ABC):
33+
""" Base class for Optimizely's config manager. """
34+
35+
def __init__(self,
36+
logger=None,
37+
error_handler=None,
38+
notification_center=None):
39+
""" Initialize config manager.
40+
41+
Args:
42+
logger: Provides a logger instance.
43+
error_handler: Provides a handle_error method to handle exceptions.
44+
notification_center: Provides instance of notification_center.NotificationCenter.
45+
"""
46+
self.logger = optimizely_logger.adapt_logger(logger or optimizely_logger.NoOpLogger())
47+
self.error_handler = error_handler or NoOpErrorHandler()
48+
self.notification_center = notification_center or NotificationCenter(self.logger)
49+
self._validate_instantiation_options()
50+
51+
def _validate_instantiation_options(self):
52+
""" Helper method to validate all parameters.
53+
54+
Raises:
55+
Exception if provided options are invalid.
56+
"""
57+
if not validator.is_logger_valid(self.logger):
58+
raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('logger'))
59+
60+
if not validator.is_error_handler_valid(self.error_handler):
61+
raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('error_handler'))
62+
63+
if not validator.is_notification_center_valid(self.notification_center):
64+
raise optimizely_exceptions.InvalidInputException(enums.Errors.INVALID_INPUT.format('notification_center'))
65+
66+
@abc.abstractmethod
67+
def get_config(self):
68+
""" Get config for use by optimizely.Optimizely.
69+
The config should be an instance of project_config.ProjectConfig."""
70+
pass
71+
72+
73+
class StaticConfigManager(BaseConfigManager):
74+
""" Config manager that returns ProjectConfig based on provided datafile. """
75+
76+
def __init__(self,
77+
datafile=None,
78+
logger=None,
79+
error_handler=None,
80+
notification_center=None,
81+
skip_json_validation=False):
82+
""" Initialize config manager. Datafile has to be provided to use.
83+
84+
Args:
85+
datafile: JSON string representing the Optimizely project.
86+
logger: Provides a logger instance.
87+
error_handler: Provides a handle_error method to handle exceptions.
88+
notification_center: Notification center to generate config update notification.
89+
skip_json_validation: Optional boolean param which allows skipping JSON schema
90+
validation upon object invocation. By default
91+
JSON schema validation will be performed.
92+
"""
93+
super(StaticConfigManager, self).__init__(logger=logger,
94+
error_handler=error_handler,
95+
notification_center=notification_center)
96+
self._config = None
97+
self.validate_schema = not skip_json_validation
98+
self._set_config(datafile)
99+
100+
def _set_config(self, datafile):
101+
""" Looks up and sets datafile and config based on response body.
102+
103+
Args:
104+
datafile: JSON string representing the Optimizely project.
105+
"""
106+
107+
if self.validate_schema:
108+
if not validator.is_datafile_valid(datafile):
109+
self.logger.error(enums.Errors.INVALID_INPUT.format('datafile'))
110+
return
111+
112+
error_msg = None
113+
error_to_handle = None
114+
config = None
115+
116+
try:
117+
config = project_config.ProjectConfig(datafile, self.logger, self.error_handler)
118+
except optimizely_exceptions.UnsupportedDatafileVersionException as error:
119+
error_msg = error.args[0]
120+
error_to_handle = error
121+
except:
122+
error_msg = enums.Errors.INVALID_INPUT.format('datafile')
123+
error_to_handle = optimizely_exceptions.InvalidInputException(error_msg)
124+
finally:
125+
if error_msg:
126+
self.logger.error(error_msg)
127+
self.error_handler.handle_error(error_to_handle)
128+
return
129+
130+
previous_revision = self._config.get_revision() if self._config else None
131+
132+
if previous_revision == config.get_revision():
133+
return
134+
135+
self._config = config
136+
self.notification_center.send_notifications(enums.NotificationTypes.OPTIMIZELY_CONFIG_UPDATE)
137+
self.logger.debug(
138+
'Received new datafile and updated config. '
139+
'Old revision number: {}. New revision number: {}.'.format(previous_revision, config.get_revision())
140+
)
141+
142+
def get_config(self):
143+
""" Returns instance of ProjectConfig.
144+
145+
Returns:
146+
ProjectConfig. None if not set.
147+
"""
148+
return self._config
149+
150+
151+
class PollingConfigManager(StaticConfigManager):
152+
""" Config manager that polls for the datafile and updated ProjectConfig based on an update interval. """
153+
154+
def __init__(self,
155+
sdk_key=None,
156+
datafile=None,
157+
update_interval=None,
158+
url=None,
159+
url_template=None,
160+
logger=None,
161+
error_handler=None,
162+
notification_center=None,
163+
skip_json_validation=False):
164+
""" Initialize config manager. One of sdk_key or url has to be set to be able to use.
165+
166+
Args:
167+
sdk_key: Optional string uniquely identifying the datafile.
168+
datafile: Optional JSON string representing the project.
169+
update_interval: Optional floating point number representing time interval in seconds
170+
at which to request datafile and set ProjectConfig.
171+
url: Optional string representing URL from where to fetch the datafile. If set it supersedes the sdk_key.
172+
url_template: Optional string template which in conjunction with sdk_key
173+
determines URL from where to fetch the datafile.
174+
logger: Provides a logger instance.
175+
error_handler: Provides a handle_error method to handle exceptions.
176+
notification_center: Notification center to generate config update notification.
177+
skip_json_validation: Optional boolean param which allows skipping JSON schema
178+
validation upon object invocation. By default
179+
JSON schema validation will be performed.
180+
181+
"""
182+
super(PollingConfigManager, self).__init__(datafile=datafile,
183+
logger=logger,
184+
error_handler=error_handler,
185+
notification_center=notification_center,
186+
skip_json_validation=skip_json_validation)
187+
self.datafile_url = self.get_datafile_url(sdk_key, url,
188+
url_template or enums.ConfigManager.DATAFILE_URL_TEMPLATE)
189+
self.set_update_interval(update_interval)
190+
self.last_modified = None
191+
self._polling_thread = threading.Thread(target=self._run)
192+
self._polling_thread.setDaemon(True)
193+
self._polling_thread.start()
194+
195+
@staticmethod
196+
def get_datafile_url(sdk_key, url, url_template):
197+
""" Helper method to determine URL from where to fetch the datafile.
198+
199+
Args:
200+
sdk_key: Key uniquely identifying the datafile.
201+
url: String representing URL from which to fetch the datafile.
202+
url_template: String representing template which is filled in with
203+
SDK key to determine URL from which to fetch the datafile.
204+
205+
Returns:
206+
String representing URL to fetch datafile from.
207+
208+
Raises:
209+
optimizely.exceptions.InvalidInputException if:
210+
- One of sdk_key or url is not provided.
211+
- url_template is invalid.
212+
"""
213+
# Ensure that either is provided by the user.
214+
if sdk_key is None and url is None:
215+
raise optimizely_exceptions.InvalidInputException('Must provide at least one of sdk_key or url.')
216+
217+
# Return URL if one is provided or use template and SDK key to get it.
218+
if url is None:
219+
try:
220+
return url_template.format(sdk_key=sdk_key)
221+
except (AttributeError, KeyError):
222+
raise optimizely_exceptions.InvalidInputException(
223+
'Invalid url_template {} provided.'.format(url_template))
224+
225+
return url
226+
227+
def set_update_interval(self, update_interval):
228+
""" Helper method to set frequency at which datafile has to be polled and ProjectConfig updated.
229+
230+
Args:
231+
update_interval: Time in seconds after which to update datafile.
232+
"""
233+
if not update_interval:
234+
update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL
235+
self.logger.debug('Set config update interval to default value {}.'.format(update_interval))
236+
237+
if not isinstance(update_interval, (int, float)):
238+
raise optimizely_exceptions.InvalidInputException(
239+
'Invalid update_interval "{}" provided.'.format(update_interval)
240+
)
241+
242+
# If polling interval is less than minimum allowed interval then set it to default update interval.
243+
if update_interval < enums.ConfigManager.MIN_UPDATE_INTERVAL:
244+
self.logger.debug('update_interval value {} too small. Defaulting to {}'.format(
245+
update_interval,
246+
enums.ConfigManager.DEFAULT_UPDATE_INTERVAL)
247+
)
248+
update_interval = enums.ConfigManager.DEFAULT_UPDATE_INTERVAL
249+
250+
self.update_interval = update_interval
251+
252+
def set_last_modified(self, response_headers):
253+
""" Looks up and sets last modified time based on Last-Modified header in the response.
254+
255+
Args:
256+
response_headers: requests.Response.headers
257+
"""
258+
self.last_modified = response_headers.get(enums.HTTPHeaders.LAST_MODIFIED)
259+
260+
def _handle_response(self, response):
261+
""" Helper method to handle response containing datafile.
262+
263+
Args:
264+
response: requests.Response
265+
"""
266+
try:
267+
response.raise_for_status()
268+
except requests_exceptions.HTTPError as err:
269+
self.logger.error('Fetching datafile from {} failed. Error: {}'.format(self.datafile_url, str(err)))
270+
return
271+
272+
# Leave datafile and config unchanged if it has not been modified.
273+
if response.status_code == http_status_codes.not_modified:
274+
self.logger.debug('Not updating config as datafile has not updated since {}.'.format(self.last_modified))
275+
return
276+
277+
self.set_last_modified(response.headers)
278+
self._set_config(response.content)
279+
280+
def fetch_datafile(self):
281+
""" Fetch datafile and set ProjectConfig. """
282+
283+
request_headers = {}
284+
if self.last_modified:
285+
request_headers[enums.HTTPHeaders.IF_MODIFIED_SINCE] = self.last_modified
286+
287+
response = requests.get(self.datafile_url,
288+
headers=request_headers,
289+
timeout=enums.ConfigManager.REQUEST_TIMEOUT)
290+
self._handle_response(response)
291+
292+
@property
293+
def is_running(self):
294+
""" Check if polling thread is alive or not. """
295+
return self._polling_thread.is_alive()
296+
297+
def _run(self):
298+
""" Triggered as part of the thread which fetches the datafile and sleeps until next update interval. """
299+
try:
300+
while self.is_running:
301+
self.fetch_datafile()
302+
time.sleep(self.update_interval)
303+
except (OSError, OverflowError) as err:
304+
self.logger.error('Error in time.sleep. '
305+
'Provided update_interval value may be too big. Error: {}'.format(str(err)))
306+
raise
307+
308+
def start(self):
309+
""" Start the config manager and the thread to periodically fetch datafile. """
310+
if not self.is_running:
311+
self._polling_thread.start()

optimizely/decision_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def get_variation_for_feature(self, project_config, feature, user_id, attributes
412412
))
413413
return Decision(experiment, variation, enums.DecisionSources.FEATURE_TEST)
414414
else:
415-
self.logger.error(enums.Errors.INVALID_GROUP_ID_ERROR.format('_get_variation_for_feature'))
415+
self.logger.error(enums.Errors.INVALID_GROUP_ID.format('_get_variation_for_feature'))
416416

417417
# Next check if the feature is being experimented on
418418
elif feature.experimentIds:

optimizely/helpers/enums.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ class AudienceEvaluationLogs(object):
3636
'newer release of the Optimizely SDK.'
3737

3838

39+
class ConfigManager(object):
40+
DATAFILE_URL_TEMPLATE = 'https://cdn.optimizely.com/datafiles/{sdk_key}.json'
41+
# Default config update interval of 5 minutes
42+
DEFAULT_UPDATE_INTERVAL = 5 * 60
43+
# Minimum config update interval of 1 second
44+
MIN_UPDATE_INTERVAL = 1
45+
# Time in seconds before which request for datafile times out
46+
REQUEST_TIMEOUT = 10
47+
48+
3949
class ControlAttributes(object):
4050
BOT_FILTERING = '$opt_bot_filtering'
4151
BUCKETING_ID = '$opt_bucketing_id'
@@ -61,24 +71,30 @@ class DecisionSources(object):
6171

6272

6373
class Errors(object):
64-
INVALID_ATTRIBUTE_ERROR = 'Provided attribute is not in datafile.'
74+
INVALID_ATTRIBUTE = 'Provided attribute is not in datafile.'
6575
INVALID_ATTRIBUTE_FORMAT = 'Attributes provided are in an invalid format.'
66-
INVALID_AUDIENCE_ERROR = 'Provided audience is not in datafile.'
67-
INVALID_DATAFILE = 'Datafile has invalid format. Failing "{}".'
76+
INVALID_AUDIENCE = 'Provided audience is not in datafile.'
6877
INVALID_EVENT_TAG_FORMAT = 'Event tags provided are in an invalid format.'
69-
INVALID_EXPERIMENT_KEY_ERROR = 'Provided experiment is not in datafile.'
70-
INVALID_EVENT_KEY_ERROR = 'Provided event is not in datafile.'
71-
INVALID_FEATURE_KEY_ERROR = 'Provided feature key is not in the datafile.'
72-
INVALID_GROUP_ID_ERROR = 'Provided group is not in datafile.'
73-
INVALID_INPUT_ERROR = 'Provided "{}" is in an invalid format.'
74-
INVALID_VARIATION_ERROR = 'Provided variation is not in datafile.'
75-
INVALID_VARIABLE_KEY_ERROR = 'Provided variable key is not in the feature flag.'
78+
INVALID_EXPERIMENT_KEY = 'Provided experiment is not in datafile.'
79+
INVALID_EVENT_KEY = 'Provided event is not in datafile.'
80+
INVALID_FEATURE_KEY = 'Provided feature key is not in the datafile.'
81+
INVALID_GROUP_ID = 'Provided group is not in datafile.'
82+
INVALID_INPUT = 'Provided "{}" is in an invalid format.'
83+
INVALID_OPTIMIZELY = 'Optimizely instance is not valid. Failing "{}".'
84+
INVALID_PROJECT_CONFIG = 'Invalid config. Optimizely instance is not valid. Failing "{}".'
85+
INVALID_VARIATION = 'Provided variation is not in datafile.'
86+
INVALID_VARIABLE_KEY = 'Provided variable key is not in the feature flag.'
7687
NONE_FEATURE_KEY_PARAMETER = '"None" is an invalid value for feature key.'
7788
NONE_USER_ID_PARAMETER = '"None" is an invalid value for user ID.'
7889
NONE_VARIABLE_KEY_PARAMETER = '"None" is an invalid value for variable key.'
7990
UNSUPPORTED_DATAFILE_VERSION = 'This version of the Python SDK does not support the given datafile version: "{}".'
8091

8192

93+
class HTTPHeaders(object):
94+
IF_MODIFIED_SINCE = 'If-Modified-Since'
95+
LAST_MODIFIED = 'Last-Modified'
96+
97+
8298
class HTTPVerbs(object):
8399
GET = 'GET'
84100
POST = 'POST'
@@ -103,9 +119,12 @@ class NotificationTypes(object):
103119
DECISION notification listener has the following parameters:
104120
DecisionNotificationTypes type, str user_id, dict attributes, dict decision_info
105121
122+
OPTIMIZELY_CONFIG_UPDATE notification listener has no associated parameters.
123+
106124
TRACK notification listener has the following parameters:
107125
str event_key, str user_id, dict attributes (can be None), event_tags (can be None), Event event
108126
"""
109127
ACTIVATE = 'ACTIVATE:experiment, user_id, attributes, variation, event'
110128
DECISION = 'DECISION:type, user_id, attributes, decision_info'
129+
OPTIMIZELY_CONFIG_UPDATE = 'OPTIMIZELY_CONFIG_UPDATE'
111130
TRACK = 'TRACK:event_key, user_id, attributes, event_tags, event'

0 commit comments

Comments
 (0)