|
| 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() |
0 commit comments