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
7 changes: 2 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install test cover build
.PHONY: install test cover

install:
pipenv install --dev
Expand All @@ -7,7 +7,4 @@ test:
pytest -v --cov=./switcher_client --cov-report xml --cov-config=.coveragerc

cover:
coverage html

build:
python3 -m build
coverage html
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
Expand All @@ -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` |
Expand Down Expand Up @@ -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')
```

Expand Down
4 changes: 3 additions & 1 deletion switcher_client/lib/globals/global_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions switcher_client/lib/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions switcher_client/lib/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
47 changes: 46 additions & 1 deletion switcher_client/switcher.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Optional

from .lib.globals.global_context import Context
Expand All @@ -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)
Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion switcher_client/switcher_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/playground/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions tests/playground/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions tests/test_switcher_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down
32 changes: 20 additions & 12 deletions tests/test_switcher_throttle.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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

Expand All @@ -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))):
Expand All @@ -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,
Expand Down