diff --git a/Makefile b/Makefile index 313f1c5..0914e1f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test cover build +.PHONY: install test cover install: pipenv install --dev @@ -7,7 +7,4 @@ test: pytest -v --cov=./switcher_client --cov-report xml --cov-config=.coveragerc cover: - coverage html - -build: - python3 -m build \ No newline at end of file + coverage html \ No newline at end of file diff --git a/README.md b/README.md index e42e71b..1580e61 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Client.build_context( snapshot_location='./snapshot/', # Snapshot files location snapshot_auto_update_interval=3, # Auto-update interval (seconds) silent_mode='5m', # Silent mode retry time + throttle_max_workers=2, # Max workers for throttling cert_path='./certs/ca.pem' # 🚧 TODO: Certificate path ) ) @@ -147,6 +148,8 @@ switcher = Client.get_switcher() | `local` | `bool` | Use local snapshot files only (zero latency) | `False` | | `snapshot_location` | `str` | Directory for snapshot files | `'./snapshot/'` | | `snapshot_auto_update_interval` | `int` | Auto-update interval in seconds (0 = disabled) | `0` | +| `silent_mode` | `str` | Silent mode retry time (e.g., '5m' for 5 minutes) | `None` | +| `throttle_max_workers` | `int` | Max workers for throttling feature checks | `None` | | `regex_safe` | `bool` | Enable ReDoS attack protection | `True` | | `regex_max_black_list` | `int` | Max cached entries for failed regex | `50` | | `regex_max_time_limit` | `int` | Regex execution time limit (ms) | `3000` | @@ -239,9 +242,9 @@ Client.subscribe_notify_error(lambda error: print(f"Switcher Error: {error}")) The following features are currently in development: -#### Throttling (Coming Soon) +#### Throttling ```python -# 🚧 TODO: Zero-latency async execution +# Zero-latency async execution switcher.throttle(1000).is_on('FEATURE01') ``` diff --git a/switcher_client/lib/globals/global_context.py b/switcher_client/lib/globals/global_context.py index 70389fe..6ee7228 100644 --- a/switcher_client/lib/globals/global_context.py +++ b/switcher_client/lib/globals/global_context.py @@ -9,12 +9,14 @@ def __init__(self, logger = False, snapshot_location: Optional[str] = None, snapshot_auto_update_interval: Optional[int] = None, - silent_mode: Optional[str] = None): + silent_mode: Optional[str] = None, + throttle_max_workers: Optional[int] = None): self.local = local self.logger = logger self.snapshot_location = snapshot_location self.snapshot_auto_update_interval = snapshot_auto_update_interval self.silent_mode = silent_mode + self.throttle_max_workers = throttle_max_workers class Context: def __init__(self, diff --git a/switcher_client/lib/types.py b/switcher_client/lib/types.py index 2d0d29f..48ac02a 100644 --- a/switcher_client/lib/types.py +++ b/switcher_client/lib/types.py @@ -18,6 +18,13 @@ def disabled(reason: str, metadata: Optional[dict] = None) -> 'ResultDetail': def success(reason: str = "Success", metadata: Optional[dict] = None) -> 'ResultDetail': return ResultDetail(result=True, reason=reason, metadata=metadata) + def to_dict(self) -> dict: + return { + 'result': self.result, + 'reason': self.reason, + 'metadata': self.metadata + } + class Domain: def __init__(self): self.name: str diff --git a/switcher_client/lib/utils/__init__.py b/switcher_client/lib/utils/__init__.py index 4828e3a..276b687 100644 --- a/switcher_client/lib/utils/__init__.py +++ b/switcher_client/lib/utils/__init__.py @@ -1,6 +1,4 @@ -from typing import Optional - -from switcher_client.lib.types import Entry +from ...lib.types import Entry from .execution_logger import ExecutionLogger def get(value, default_value): diff --git a/switcher_client/switcher.py b/switcher_client/switcher.py index 9507607..5f7567c 100644 --- a/switcher_client/switcher.py +++ b/switcher_client/switcher.py @@ -1,3 +1,5 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime from typing import Optional from .lib.globals.global_context import Context @@ -12,10 +14,17 @@ class Switcher(SwitcherData): def __init__(self, context: Context, key: Optional[str] = None): - super().__init__(key) + super().__init__(context, key) self._context = context + self._init_worker(context) self._validate_args(key) + def _init_worker(self, context: Context): + self._background_executor = ThreadPoolExecutor( + max_workers=context.options.throttle_max_workers, + thread_name_prefix="SwitcherBackgroundRefresh" + ) + def prepare(self, key: Optional[str] = None): """ Checks API credentials and connectivity """ self._validate_args(key) @@ -25,15 +34,36 @@ def is_on(self, key: Optional[str] = None) -> bool: """ Execute criteria """ self._show_details = False self._validate_args(key, details=False) + + # try get cached result + cached_result = self._try_cached_result() + if cached_result is not None: + return cached_result.result + return self._submit().result def is_on_with_details(self, key: Optional[str] = None) -> ResultDetail: """ Execute criteria with details """ self._validate_args(key, details=True) + + # try get cached result + cached_result = self._try_cached_result() + if cached_result is not None: + return cached_result + return self._submit() + def schedule_background_refresh(self): + """ Schedules background refresh of the last criteria request """ + now = int(datetime.now().timestamp() * 1000) + + if now > self._next_refresh_time: + self._next_refresh_time = now + self._throttle_period + self._background_executor.submit(self._submit) + def _submit(self) -> ResultDetail: """ Submit criteria for execution (local or remote) """ + # verify if query from snapshot if (self._context.options.local): return self._execute_local_criteria() @@ -83,6 +113,17 @@ def _execute_api_checks(self): if RemoteAuth.is_token_expired(): self.prepare(self._key) + def _try_cached_result(self) -> Optional[ResultDetail]: + """ Try get cached result if throttle is enabled and criteria was recently executed """ + if self._has_throttle(): + self.schedule_background_refresh() + + cached_result_logger = ExecutionLogger.get_execution(self._key, self._input) + if cached_result_logger.key is not None: + return cached_result_logger.response + + return None + def _notify_error(self, error: Exception): """ Notify asynchronous error to the subscribed callback """ if ExecutionLogger._callback_error: @@ -116,6 +157,10 @@ def _can_log(self) -> bool: """ Check if logging is enabled """ return self._context.options.logger and self._key is not None + def _has_throttle(self) -> bool: + """ Check if throttle is enabled and criteria was recently executed """ + return self._throttle_period != 0 + def _get_default_result_or_raise(self, e) -> ResultDetail: """ Get default result if set, otherwise raise the error """ if self._default_result is None: diff --git a/switcher_client/switcher_data.py b/switcher_client/switcher_data.py index 95f92e2..7163c39 100644 --- a/switcher_client/switcher_data.py +++ b/switcher_client/switcher_data.py @@ -2,10 +2,12 @@ from abc import ABCMeta from typing import Optional, Self, Union +from .lib.globals.global_context import Context from .lib.snapshot import StrategiesType class SwitcherData(metaclass=ABCMeta): - def __init__(self, key: Optional[str] = None): + def __init__(self, context: Context,key: Optional[str] = None): + self._context = context self._key = key self._input = [] self._show_details = False @@ -48,6 +50,9 @@ def throttle(self, period: int) -> Self: if self._next_refresh_time == 0: self._next_refresh_time = int(datetime.now().timestamp() * 1000) + period + if self._throttle_period > 0: + self._context.options.logger = True + return self def default_result(self, result: bool) -> Self: diff --git a/tests/playground/index.py b/tests/playground/index.py index 6627fde..91f070b 100644 --- a/tests/playground/index.py +++ b/tests/playground/index.py @@ -30,6 +30,18 @@ def simple_api_call(): monitor_thread = threading.Thread(target=monitor_run, args=(switcher,), daemon=True) monitor_thread.start() +def simple_api_call_with_throttle(): + """ Use case: Check Switcher using remote API with throttle """ + setup_context(ContextOptions( + local=False + )) + + switcher = Client.get_switcher(SWITCHER_KEY) + switcher.throttle(period=5000) # 5 seconds + + monitor_thread = threading.Thread(target=monitor_run, args=(switcher,True), daemon=True) + monitor_thread.start() + def load_snapshot_from_remote(): """ Use case: Load snapshot from remote API """ global LOOP diff --git a/tests/playground/util.py b/tests/playground/util.py index d32ca2d..5f2437e 100644 --- a/tests/playground/util.py +++ b/tests/playground/util.py @@ -3,12 +3,13 @@ from switcher_client import Switcher -def monitor_run(switcher: Switcher): +def monitor_run(switcher: Switcher, details=False): while True: start_time = time.time() * 1000 - result = switcher.is_on() + if details: result = switcher.is_on_with_details() + else: result = switcher.is_on() end_time = time.time() * 1000 elapsed_time = int(end_time - start_time) - print(f"- {elapsed_time} ms - {json.dumps(result)}") + print(f"- {elapsed_time} ms - {result if isinstance(result, bool) else json.dumps(result.to_dict())}") time.sleep(1.0) \ No newline at end of file diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py index 4f955ec..0822c08 100644 --- a/tests/test_switcher_remote.py +++ b/tests/test_switcher_remote.py @@ -85,6 +85,7 @@ def test_remote_with_details(httpx_mock): assert response.reason == 'Success' assert response.result is True assert response.metadata == {'key': 'value'} + assert isinstance(response.to_dict(), dict) def test_remote_renew_token(httpx_mock): """ Should renew the token when it is expired """ diff --git a/tests/test_switcher_throttle.py b/tests/test_switcher_throttle.py index f0d7b84..722f653 100644 --- a/tests/test_switcher_throttle.py +++ b/tests/test_switcher_throttle.py @@ -1,15 +1,13 @@ -import pytest import time from typing import Optional from pytest_httpx import HTTPXMock -from switcher_client.errors import RemoteAuthError from switcher_client import Client -from switcher_client.lib.globals.global_auth import GlobalAuth +from switcher_client.lib.globals.global_context import ContextOptions def test_throttle(httpx_mock): - """ TODO Should throttle remote API calls and use cached response """ + """ Should throttle remote API calls and use cached response """ # given given_auth(httpx_mock) @@ -20,14 +18,23 @@ def test_throttle(httpx_mock): switcher.throttle(1000) # 1 second throttle # test - response = switcher.is_on_with_details('MY_SWITCHER') + response = switcher.is_on_with_details('MY_SWITCHER_THROTTLE') assert response.result is True assert response.metadata == {} - # when - call again within throttle period - # response = switcher.is_on_with_details('MY_SWITCHER') - # assert response.result is True - # assert response.metadata == {'cached': True} + # when - call again within throttle period (no new API call should be made, cached response should be used) + response = switcher.is_on_with_details('MY_SWITCHER_THROTTLE') + assert switcher.is_on('MY_SWITCHER_THROTTLE') is True + assert response.result is True + assert response.metadata == {'cached': True} + + time.sleep(1) + + # when - call again outside of throttle period (new API call should be made, cached response should be updated) + given_check_criteria(httpx_mock, show_details=True, response={'result': False}) + response = switcher.is_on_with_details('MY_SWITCHER_THROTTLE') + assert response.result is False + assert response.metadata == {'cached': True} # Helpers @@ -36,7 +43,8 @@ def given_context(url='https://api.switcherapi.com', api_key='[API_KEY]'): url=url, api_key=api_key, domain='Playground', - component='switcher-playground' + component='switcher-playground', + options=ContextOptions(throttle_max_workers=2) ) def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]', exp=int(round(time.time() * 1000))): @@ -47,9 +55,9 @@ def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]' json={'token': token, 'exp': exp} ) -def given_check_criteria(httpx_mock: HTTPXMock, status=200, key='MY_SWITCHER', response={}, show_details=False, match=None): +def given_check_criteria(httpx_mock: HTTPXMock, status=200, key='MY_SWITCHER_THROTTLE', response={}, show_details=False, match=None): httpx_mock.add_response( - is_reusable=True, + is_reusable=False, url=f'https://api.switcherapi.com/criteria?showReason={str(show_details).lower()}&key={key}', method='POST', status_code=status,