From 74a76f99cfa59fdc6b13cb8f97691b6022fe33da Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:51:05 -0800 Subject: [PATCH] feat: added silent_mode feature --- README.md | 2 +- switcher_client/client.py | 14 ++- switcher_client/lib/globals/__init__.py | 2 + switcher_client/lib/globals/global_context.py | 4 +- switcher_client/lib/globals/global_retry.py | 8 ++ switcher_client/lib/remote.py | 7 ++ switcher_client/lib/remote_auth.py | 27 +++++- switcher_client/lib/utils/date_moment.py | 26 ++++++ switcher_client/switcher.py | 14 ++- tests/test_switcher_silent_mode.py | 88 +++++++++++++++++++ tests/utils/test_date_moment.py | 36 ++++++++ tests/utils/test_timed_match.py | 24 ++--- 12 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 switcher_client/lib/globals/global_retry.py create mode 100644 switcher_client/lib/utils/date_moment.py create mode 100644 tests/test_switcher_silent_mode.py create mode 100644 tests/utils/test_date_moment.py diff --git a/README.md b/README.md index 991f636..61f50bd 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Client.build_context( logger=True, # Enable logging snapshot_location='./snapshot/', # Snapshot files location snapshot_auto_update_interval=3, # Auto-update interval (seconds) - silent_mode='5m', # 🚧 TODO: Silent mode retry time + silent_mode='5m', # Silent mode retry time cert_path='./certs/ca.pem' # 🚧 TODO: Certificate path ) ) diff --git a/switcher_client/client.py b/switcher_client/client.py index cdbd0a0..906fc14 100644 --- a/switcher_client/client.py +++ b/switcher_client/client.py @@ -1,9 +1,8 @@ from typing import Optional, Callable from .lib.globals.global_snapshot import GlobalSnapshot, LoadSnapshotOptions +from .lib.globals.global_context import Context, ContextOptions, DEFAULT_ENVIRONMENT from .lib.remote_auth import RemoteAuth -from .lib.globals.global_context import Context, ContextOptions -from .lib.globals.global_context import DEFAULT_ENVIRONMENT from .lib.snapshot_auto_updater import SnapshotAutoUpdater from .lib.snapshot_loader import load_domain, validate_snapshot, save_snapshot from .lib.utils.execution_logger import ExecutionLogger @@ -13,6 +12,7 @@ class SwitcherOptions: SNAPSHOT_AUTO_UPDATE_INTERVAL = 'snapshot_auto_update_interval' + SILENT_MODE = 'silent_mode' class Client: _context: Context = Context.empty() @@ -52,13 +52,21 @@ def build_context( @staticmethod def _build_options(options: ContextOptions): options_handler = { - SwitcherOptions.SNAPSHOT_AUTO_UPDATE_INTERVAL: lambda: Client.schedule_snapshot_auto_update() + SwitcherOptions.SNAPSHOT_AUTO_UPDATE_INTERVAL: lambda: Client.schedule_snapshot_auto_update(), + SwitcherOptions.SILENT_MODE: lambda: Client._init_silent_mode(get(options.silent_mode, '')) } for option_key, handler in options_handler.items(): if hasattr(options, option_key) and getattr(options, option_key) is not None: handler() + @staticmethod + def _init_silent_mode(silent_mode: str): + if silent_mode != '': + RemoteAuth.set_retry_options(silent_mode) + Client._context.options.silent_mode = silent_mode + Client.load_snapshot() + @staticmethod def get_switcher(key: Optional[str] = None) -> Switcher: """ diff --git a/switcher_client/lib/globals/__init__.py b/switcher_client/lib/globals/__init__.py index 6fe0bc5..38cf4f2 100644 --- a/switcher_client/lib/globals/__init__.py +++ b/switcher_client/lib/globals/__init__.py @@ -1,8 +1,10 @@ from .global_auth import GlobalAuth from .global_context import Context, ContextOptions +from .global_retry import RetryOptions __all__ = [ "GlobalAuth", "Context", "ContextOptions", + "RetryOptions", ] \ No newline at end of file diff --git a/switcher_client/lib/globals/global_context.py b/switcher_client/lib/globals/global_context.py index 7274d78..70389fe 100644 --- a/switcher_client/lib/globals/global_context.py +++ b/switcher_client/lib/globals/global_context.py @@ -8,11 +8,13 @@ def __init__(self, local = DEFAULT_LOCAL, logger = False, snapshot_location: Optional[str] = None, - snapshot_auto_update_interval: Optional[int] = None): + snapshot_auto_update_interval: Optional[int] = None, + silent_mode: Optional[str] = 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 class Context: def __init__(self, diff --git a/switcher_client/lib/globals/global_retry.py b/switcher_client/lib/globals/global_retry.py new file mode 100644 index 0000000..1cbf036 --- /dev/null +++ b/switcher_client/lib/globals/global_retry.py @@ -0,0 +1,8 @@ +class RetryOptions: + def __init__(self, retry_time: int, retry_duration_in: str): + """ + :param retry_time: The maximum number of retries + :param retry_duration_in: The duration to wait between retries (e.g. '5s' (s: seconds - m: minutes - h: hours)) + """ + self.retry_time = retry_time + self.retry_duration_in = retry_duration_in \ No newline at end of file diff --git a/switcher_client/lib/remote.py b/switcher_client/lib/remote.py index f04b012..765c1c3 100644 --- a/switcher_client/lib/remote.py +++ b/switcher_client/lib/remote.py @@ -29,6 +29,13 @@ def auth(context: Context): raise RemoteAuthError('Invalid API key') + @staticmethod + def check_api_health(context: Context) -> bool: + url = f'{context.url}/check' + response = Remote._do_get(url) + + return response.status_code == 200 + @staticmethod def check_criteria(token: Optional[str], context: Context, switcher: SwitcherData) -> ResultDetail: url = f'{context.url}/criteria?showReason={str(switcher._show_details).lower()}&key={switcher._key}' diff --git a/switcher_client/lib/remote_auth.py b/switcher_client/lib/remote_auth.py index 70d3cee..a4be950 100644 --- a/switcher_client/lib/remote_auth.py +++ b/switcher_client/lib/remote_auth.py @@ -1,17 +1,27 @@ from time import time +from datetime import datetime from .remote import Remote from .globals.global_context import Context -from .globals import GlobalAuth +from .globals import GlobalAuth, RetryOptions +from .utils.date_moment import DateMoment class RemoteAuth: __context: Context = Context.empty() + __retry_options: RetryOptions @staticmethod def init(context: Context): RemoteAuth.__context = context GlobalAuth.init() + @staticmethod + def set_retry_options(silent_mode: str): + RemoteAuth.__retry_options = RetryOptions( + retry_time=int(silent_mode[:-1]), + retry_duration_in=silent_mode[-1] + ) + @staticmethod def auth(): token, exp = Remote.auth(RemoteAuth.__context) @@ -22,6 +32,11 @@ def auth(): def check_health(): if GlobalAuth.get_token() != 'SILENT': return + + if RemoteAuth.is_token_expired(): + RemoteAuth.update_silent_token() + if Remote.check_api_health(RemoteAuth.__context): + RemoteAuth.auth() @staticmethod def is_token_expired() -> bool: @@ -31,6 +46,16 @@ def is_token_expired() -> bool: return float(exp) < time() + @staticmethod + def update_silent_token(): + expiration_time = DateMoment(datetime.now()).add( + RemoteAuth.__retry_options.retry_time, + RemoteAuth.__retry_options.retry_duration_in + ).get_date().timestamp() + + GlobalAuth.set_token('SILENT') + GlobalAuth.set_exp(str(round(expiration_time))) + @staticmethod def is_valid(): required_fields = [ diff --git a/switcher_client/lib/utils/date_moment.py b/switcher_client/lib/utils/date_moment.py new file mode 100644 index 0000000..c1b20cd --- /dev/null +++ b/switcher_client/lib/utils/date_moment.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta + +class DateMoment: + """ + A utility class for date and time manipulation, similar to moment.js functionality. + """ + + def __init__(self, date: datetime): + self.date = date + + def get_date(self) -> datetime: + return self.date + + def add(self, amount: int, unit: str) -> 'DateMoment': + unit_lower = unit.lower() + + if unit_lower == 's': + self.date += timedelta(seconds=amount) + elif unit_lower == 'm': + self.date += timedelta(minutes=amount) + elif unit_lower == 'h': + self.date += timedelta(hours=amount) + else: + raise ValueError(f"Unit {unit} not compatible - try [s, m or h]") + + return self \ No newline at end of file diff --git a/switcher_client/switcher.py b/switcher_client/switcher.py index 6b3c5ea..112b94e 100644 --- a/switcher_client/switcher.py +++ b/switcher_client/switcher.py @@ -37,10 +37,18 @@ def _submit(self) -> ResultDetail: if (self._context.options.local): return self._execute_local_criteria() - self.validate() - response = self._execute_remote_criteria() + try: + self.validate() + if GlobalAuth.get_token() == 'SILENT': + return self._execute_local_criteria() - return response + return self._execute_remote_criteria() + except Exception as e: + if self._context.options.silent_mode: + RemoteAuth.update_silent_token() + return self._execute_local_criteria() + + raise e def validate(self) -> 'Switcher': """ Validates client settings for remote API calls """ diff --git a/tests/test_switcher_silent_mode.py b/tests/test_switcher_silent_mode.py new file mode 100644 index 0000000..61051ae --- /dev/null +++ b/tests/test_switcher_silent_mode.py @@ -0,0 +1,88 @@ +import time + +from typing import Optional +from pytest_httpx import HTTPXMock + +from switcher_client import Client +from switcher_client.lib.globals.global_context import ContextOptions + +def test_silent_mode_for_check_criteria(httpx_mock): + """ Should use the silent mode when the remote API is not available for check criteria """ + + # given + given_auth(httpx_mock) + given_check_criteria(httpx_mock, key='FF2FOR2022', response={'error': 'Too many requests'}, status=429) + given_check_health(httpx_mock, status=500) + given_context(silent_mode='1s') + + switcher = Client.get_switcher('FF2FOR2022') + + # test + # assert silent mode being used while registering the error + assert switcher.is_on('FF2FOR2022') + + # assert silent mode being used in the next call + time.sleep(1.5) + assert switcher.is_on('FF2FOR2022') + +def test_silent_mode_for_check_criteria_restabilished(httpx_mock): + """ Should retry check criteria once the remote API is restabilished and the token is renewed """ + + # given + given_auth(httpx_mock) + given_check_criteria(httpx_mock, key='FF2FOR2022', response={'error': 'Too many requests'}, status=429) + given_context(silent_mode='1s') + + switcher = Client.get_switcher('FF2FOR2022') + + # test + # assert silent mode being used while registering the error + assert switcher.is_on('FF2FOR2022') + + # assert from remote API once the API is restabilished and the token is renewed + given_check_criteria(httpx_mock, key='FF2FOR2022', response={'result': True}, status=200) + given_check_health(httpx_mock, status=200) + time.sleep(1.5) + assert switcher.is_on('FF2FOR2022') + +# Helpers + +def given_context(silent_mode: Optional[str] = '5m'): + Client.build_context( + url='https://api.switcherapi.com', + api_key='[API_KEY]', + domain='Playground', + component='switcher-playground', + options=ContextOptions( + silent_mode=silent_mode, + snapshot_location='tests/snapshots', + ) + ) + +def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]', exp=int(round(time.time() * 1000))): + httpx_mock.add_response( + is_reusable=True, + url='https://api.switcherapi.com/criteria/auth', + method='POST', + status_code=status, + json={'token': token, 'exp': exp} + ) + +def given_check_criteria(httpx_mock: HTTPXMock, status=200, key='MY_SWITCHER', response={}, show_details=False, match=None): + httpx_mock.add_response( + is_reusable=False, + url=f'https://api.switcherapi.com/criteria?showReason={str(show_details).lower()}&key={key}', + method='POST', + status_code=status, + json=response, + match_json=match + ) + +def given_check_health(httpx_mock: HTTPXMock, status=200): + httpx_mock.add_response( + is_reusable=False, + url='https://api.switcherapi.com/check', + method='GET', + status_code=status, + ) + diff --git a/tests/utils/test_date_moment.py b/tests/utils/test_date_moment.py new file mode 100644 index 0000000..a4974e4 --- /dev/null +++ b/tests/utils/test_date_moment.py @@ -0,0 +1,36 @@ +import pytest + +from datetime import datetime +from switcher_client.lib.utils.date_moment import DateMoment + +class TestDateMoment: + """ DateMoment tests """ + + def test_should_add_1_second_to_date(self): + """ Should add 1 second to date """ + today_moment = DateMoment(datetime.now().replace(hour=10, minute=0, second=0, microsecond=0)) + before_adding = today_moment.get_date().second + after_adding = today_moment.add(1, 's').get_date().second + diff = after_adding - before_adding + assert diff == 1 + + def test_should_add_1_minute_to_date(self): + """ Should add 1 minute to date """ + today_moment = DateMoment(datetime.now().replace(hour=10, minute=0, second=0, microsecond=0)) + before_adding = today_moment.get_date().minute + after_adding = today_moment.add(1, 'm').get_date().minute + assert (after_adding - before_adding) == 1 + + def test_should_add_1_hour_to_date(self): + """ Should add 1 hour to date """ + today_moment = DateMoment(datetime.now().replace(hour=10, minute=0, second=0, microsecond=0)) + before_adding = today_moment.get_date().hour + after_adding = today_moment.add(1, 'h').get_date().hour + assert (after_adding - before_adding) == 1 + + def test_should_return_error_for_using_not_compatible_unit(self): + """ Should return error for using not compatible unit """ + today_moment = DateMoment(datetime.now().replace(hour=10, minute=0, second=0, microsecond=0)) + with pytest.raises(ValueError) as excinfo: + today_moment.add(1, 'x') + assert 'Unit x not compatible - try [s, m or h]' in str(excinfo.value) diff --git a/tests/utils/test_timed_match.py b/tests/utils/test_timed_match.py index 825bf62..0c92b68 100644 --- a/tests/utils/test_timed_match.py +++ b/tests/utils/test_timed_match.py @@ -14,44 +14,44 @@ def get_timer(start_time: float) -> float: - """Calculate elapsed time in milliseconds.""" + """ Calculate elapsed time in milliseconds. """ return (time.time() - start_time) * 1000 class TestTimedMatch: - """Timed-Match tests.""" + """ Timed-Match tests. """ @classmethod def setup_class(cls): - """Setup before all tests.""" + """ Setup before all tests. """ TimedMatch.initialize_worker() def setup_method(self): - """Setup before each test.""" + """ Setup before each test. """ TimedMatch.clear_blacklist() TimedMatch.set_max_blacklisted(50) TimedMatch.set_max_time_limit(1000) @classmethod def teardown_class(cls): - """Cleanup after all tests.""" + """ Cleanup after all tests. """ TimedMatch.terminate_worker() # Give processes time to fully terminate time.sleep(0.2) def test_should_return_true(self): - """Should return true for simple regex match.""" + """ Should return true for simple regex match. """ result = TimedMatch.try_match([OK_RE], OK_INPUT) assert result is True def test_should_return_false_and_abort_processing(self): - """Should return false and abort processing for ReDoS pattern.""" + """ Should return false and abort processing for ReDoS pattern. """ result = TimedMatch.try_match([NOK_RE], NOK_INPUT) assert result is False def test_runs_stress_tests(self): - """Run timing stress tests.""" + """ Run timing stress tests. """ # First run - cold start timer = time.time() @@ -79,7 +79,7 @@ def test_runs_stress_tests(self): assert elapsed < WARM_TIME def test_should_rotate_blacklist(self): - """Should rotate blacklist when max size is reached.""" + """ Should rotate blacklist when max size is reached. """ TimedMatch.set_max_blacklisted(1) @@ -108,7 +108,7 @@ def test_should_rotate_blacklist(self): assert elapsed < WARM_TIME def test_should_capture_blacklisted_item_from_multiple_regex_options(self): - """Should capture blacklisted item from multiple regex options.""" + """ Should capture blacklisted item from multiple regex options. """ TimedMatch.set_max_blacklisted(1) @@ -125,7 +125,7 @@ def test_should_capture_blacklisted_item_from_multiple_regex_options(self): assert elapsed < WARM_TIME def test_should_capture_blacklisted_item_from_similar_inputs(self): - """Should capture blacklisted item from similar inputs.""" + """ Should capture blacklisted item from similar inputs. """ TimedMatch.set_max_blacklisted(1) @@ -154,7 +154,7 @@ def test_should_capture_blacklisted_item_from_similar_inputs(self): assert elapsed < WARM_TIME def test_should_reduce_worker_timer(self): - """Should respect reduced worker timer setting.""" + """ Should respect reduced worker timer setting. """ TimedMatch.set_max_time_limit(500)