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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
Expand Down
14 changes: 11 additions & 3 deletions switcher_client/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,6 +12,7 @@

class SwitcherOptions:
SNAPSHOT_AUTO_UPDATE_INTERVAL = 'snapshot_auto_update_interval'
SILENT_MODE = 'silent_mode'

class Client:
_context: Context = Context.empty()
Expand Down Expand Up @@ -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:
"""
Expand Down
2 changes: 2 additions & 0 deletions switcher_client/lib/globals/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
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 @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions switcher_client/lib/globals/global_retry.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions switcher_client/lib/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down
27 changes: 26 additions & 1 deletion switcher_client/lib/remote_auth.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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:
Expand All @@ -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 = [
Expand Down
26 changes: 26 additions & 0 deletions switcher_client/lib/utils/date_moment.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 11 additions & 3 deletions switcher_client/switcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down
88 changes: 88 additions & 0 deletions tests/test_switcher_silent_mode.py
Original file line number Diff line number Diff line change
@@ -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,
)

36 changes: 36 additions & 0 deletions tests/utils/test_date_moment.py
Original file line number Diff line number Diff line change
@@ -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)
Loading