diff --git a/README.md b/README.md index 5ac05a2..bee36a0 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ switcher = Client.get_switcher() Enable additional features like local mode, silent mode, and security options: ```python -from switcher_client import Client, ContextOptions +from switcher_client import Client, ContextOptions, RemoteOptions Client.build_context( domain='My Domain', @@ -147,7 +147,13 @@ Client.build_context( throttle_max_workers=2, regex_max_black_list=10, regex_max_time_limit=100, - cert_path='./certs/ca.pem' + remote=RemoteOptions( + cert_path='./certs/ca.pem', + connect_timeout=0.3, + read_timeout=5.0, + write_timeout=5.0, + pool_timeout=5.0 + ) ) ) @@ -168,7 +174,21 @@ switcher = Client.get_switcher() | `throttle_max_workers` | `int` | Max workers for throttling feature checks | `None` | | `regex_max_black_list` | `int` | Max cached entries for failed regex | `100` | | `regex_max_time_limit` | `int` | Regex execution time limit (ms) | `3000` | +| `remote` | `RemoteOptions` | Remote transport settings for certificate path, connect/read/write/pool timeouts | `RemoteOptions()` | + +`RemoteOptions` fields: + +| Option | Type | Description | Default | +|--------|------|-------------|---------| | `cert_path` | `str` | Path to custom certificate for secure API connections | `None` | +| `connect_timeout` | `float` | Max seconds to establish a remote connection before failing fast | `0.3` | +| `read_timeout` | `float` | Max seconds to wait for remote response data | `5.0` | +| `write_timeout` | `float` | Max seconds to send remote request data | `5.0` | +| `pool_timeout` | `float` | Max seconds to wait for a pooled HTTP connection | `5.0` | + +`response.status_code` is only available when the upstream returns an HTTP response such as `503`. +When the upstream is unavailable, the client raises a transport error instead and silent mode now +uses the configured remote timeouts to fail fast and switch back to local evaluation. #### Security Features diff --git a/switcher_client/__init__.py b/switcher_client/__init__.py index 4e9fede..5414f0e 100644 --- a/switcher_client/__init__.py +++ b/switcher_client/__init__.py @@ -1,6 +1,6 @@ from switcher_client.client import Client from switcher_client.switcher import Switcher -from switcher_client.lib.globals.global_context import ContextOptions +from switcher_client.lib.globals.global_context import ContextOptions, RemoteOptions from switcher_client.lib.globals.global_snapshot import LoadSnapshotOptions from switcher_client.lib.globals.global_retry import RetryOptions from switcher_client.lib.snapshot_watcher import WatchSnapshotCallback @@ -11,6 +11,7 @@ 'Client', 'Switcher', 'ContextOptions', + 'RemoteOptions', 'LoadSnapshotOptions', 'RetryOptions', 'WatchSnapshotCallback', diff --git a/switcher_client/client.py b/switcher_client/client.py index d68cbce..3cec386 100644 --- a/switcher_client/client.py +++ b/switcher_client/client.py @@ -54,7 +54,7 @@ def build_context(*, api_key: Optional[str] = None, component: Optional[str] = None, environment: Optional[str] = DEFAULT_ENVIRONMENT, - options = ContextOptions()): + options: Optional[ContextOptions] = None): """ Build the context for the client @@ -66,25 +66,27 @@ def build_context(*, :param options: Optional parameters """ + context_options = ContextOptions() if options is None else options + Client._context = Context( - domain=domain, url=url, + url=url, + domain=domain, api_key=api_key, component=component, environment=environment, - options=options) + options=context_options) # Default values Client._test_mode = DEFAULT_TEST_MODE Bypasser.clear() GlobalSnapshot.clear() - # Build Options - if options is not None: - Client._build_options(options) - # Initialize Auth RemoteAuth.init(Client._context) + # Build Options + Client._build_options(context_options) + @staticmethod def _build_options(options: ContextOptions): options_handler = { diff --git a/switcher_client/lib/globals/global_context.py b/switcher_client/lib/globals/global_context.py index 1b7a04c..e23c84f 100644 --- a/switcher_client/lib/globals/global_context.py +++ b/switcher_client/lib/globals/global_context.py @@ -8,8 +8,29 @@ DEFAULT_RESTRICT_RELAY = True DEFAULT_REGEX_MAX_BLACKLISTED = 100 DEFAULT_REGEX_MAX_TIME_LIMIT = 3000 +DEFAULT_REMOTE_CONNECT_TIMEOUT = 0.3 +DEFAULT_REMOTE_READ_TIMEOUT = 5.0 +DEFAULT_REMOTE_WRITE_TIMEOUT = 5.0 +DEFAULT_REMOTE_POOL_TIMEOUT = 5.0 DEFAULT_TEST_MODE = False +@dataclass +class RemoteOptions: + """ + :param cert_path: The path to the SSL certificate file for secure connections. + If not set, it will use the default system certificates + :param connect_timeout: Max time in seconds to establish a remote connection. + Lower values help silent mode trip faster when the upstream is unavailable + :param read_timeout: Max time in seconds to wait for a remote response body + :param write_timeout: Max time in seconds to send a remote request body + :param pool_timeout: Max time in seconds to wait for a pooled connection + """ + cert_path: Optional[str] = None + connect_timeout: float = DEFAULT_REMOTE_CONNECT_TIMEOUT + read_timeout: float = DEFAULT_REMOTE_READ_TIMEOUT + write_timeout: float = DEFAULT_REMOTE_WRITE_TIMEOUT + pool_timeout: float = DEFAULT_REMOTE_POOL_TIMEOUT + @dataclass class ContextOptions: """ @@ -35,8 +56,7 @@ class ContextOptions: matching. If not set, it will use the default value of 3000 ms :param restrict_relay: When enabled it will restrict the use of relay when local is enabled. Default is True - :param cert_path: The path to the SSL certificate file for secure connections. - If not set, it will use the default system certificates + :param remote: Remote transport settings such as certificate path, connect/read/write/pool timeouts """ # pylint: disable=too-many-arguments, too-many-instance-attributes def __init__(self, *, @@ -50,7 +70,7 @@ def __init__(self, *, snapshot_auto_update_interval: Optional[int] = None, silent_mode: Optional[str] = None, throttle_max_workers: Optional[int] = None, - cert_path: Optional[str] = None): + remote: Optional[RemoteOptions] = None): self.local = local self.logger = logger self.freeze = freeze @@ -61,7 +81,7 @@ def __init__(self, *, self.throttle_max_workers = throttle_max_workers self.regex_max_black_list = regex_max_black_list self.regex_max_time_limit = regex_max_time_limit - self.cert_path = cert_path + self.remote = remote or RemoteOptions() @dataclass class Context: @@ -76,13 +96,13 @@ class Context: # pylint: disable=too-many-arguments def __init__(self, *, domain: Optional[str], url: Optional[str], api_key: Optional[str], component: Optional[str], - environment: Optional[str], options: ContextOptions = ContextOptions()): + environment: Optional[str], options: Optional[ContextOptions] = None): self.domain = domain self.url = url self.api_key = api_key self.component = component self.environment = environment - self.options = options + self.options = ContextOptions() if options is None else options @classmethod def empty(cls): diff --git a/switcher_client/lib/remote.py b/switcher_client/lib/remote.py index c8b7699..95daf22 100644 --- a/switcher_client/lib/remote.py +++ b/switcher_client/lib/remote.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Callable, Optional, Type import json import ssl @@ -16,18 +16,26 @@ class Remote: including authentication, criteria checks, and snapshot resolution. """ _client: Optional[httpx.Client] = None + _client_config: Optional[tuple[Optional[str], float, float, float, float]] = None @staticmethod def auth(context: Context): url = f'{context.url}/criteria/auth' - response = Remote._do_post(context, url, { - 'domain': context.domain, - 'component': context.component, - 'environment': context.environment, - }, { - 'switcher-api-key': context.api_key, - 'Content-Type': 'application/json', - }) + response = Remote._do_post( + context=context, + url=url, + data={ + 'domain': context.domain, + 'component': context.component, + 'environment': context.environment, + }, + headers={ + 'switcher-api-key': context.api_key, + 'Content-Type': 'application/json', + }, + operation='auth', + error_cls=RemoteAuthError + ) if response.status_code == 200: return response.json()['token'], response.json()['exp'] @@ -37,7 +45,10 @@ def auth(context: Context): @staticmethod def check_api_health(context: Context) -> bool: url = f'{context.url}/check' - response = Remote._do_get(context, url) + try: + response = Remote._do_get(context=context, url=url, operation='check_api_health') + except RemoteError: + return False return response.status_code == 200 @@ -45,7 +56,14 @@ def check_api_health(context: Context) -> bool: 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}' entry = get_entry(switcher.inputs) - response = Remote._do_post(context, url, { 'entry': [e.to_dict() for e in entry] }, Remote._get_header(token)) + response = Remote._do_post( + context=context, + url=url, + data={ 'entry': [e.to_dict() for e in entry] }, + headers=Remote._get_header(token), + operation='check_criteria', + error_cls=RemoteCriteriaError + ) if response.status_code == 200: json_response = response.json() @@ -60,7 +78,13 @@ def check_criteria(token: Optional[str], context: Context, switcher: SwitcherDat @staticmethod def check_switchers(token: Optional[str], switcher_keys: list[str], context: Context) -> None: url = f'{context.url}/criteria/switchers_check' - response = Remote._do_post(context, url, { 'switchers': switcher_keys }, Remote._get_header(token)) + response = Remote._do_post( + context=context, + url=url, + data={ 'switchers': switcher_keys }, + headers=Remote._get_header(token), + operation='check_switchers' + ) if response.status_code != 200: raise RemoteError(f'[check_switchers] failed with status: {response.status_code}') @@ -72,7 +96,12 @@ def check_switchers(token: Optional[str], switcher_keys: list[str], context: Con @staticmethod def check_snapshot_version(token: Optional[str], context: Context, snapshot_version: int) -> bool: url = f'{context.url}/criteria/snapshot_check/{snapshot_version}' - response = Remote._do_get(context, url, Remote._get_header(token)) + response = Remote._do_get( + context=context, + url=url, + headers=Remote._get_header(token), + operation='check_snapshot_version' + ) if response.status_code == 200: return response.json().get('status', False) @@ -102,7 +131,13 @@ def resolve_snapshot(token: Optional[str], context: Context) -> str | None: """ } - response = Remote._do_post(context, f'{context.url}/graphql', data, Remote._get_header(token)) + response = Remote._do_post( + context=context, + url=f'{context.url}/graphql', + data=data, + headers=Remote._get_header(token), + operation='resolve_snapshot' + ) if response.status_code == 200: return json.dumps(response.json().get('data', {}), indent=4) @@ -111,9 +146,18 @@ def resolve_snapshot(token: Optional[str], context: Context) -> str | None: @classmethod def _get_client(cls, context: Context) -> httpx.Client: - if cls._client is None or cls._client.is_closed: + client_config = cls._get_client_config(context) + if cls._client is None or cls._client.is_closed or cls._client_config != client_config: + if cls._client is not None and not cls._client.is_closed: + cls._client.close() + cls._client = httpx.Client( - timeout=30.0, + timeout=httpx.Timeout( + connect=context.options.remote.connect_timeout, + read=context.options.remote.read_timeout, + write=context.options.remote.write_timeout, + pool=context.options.remote.pool_timeout + ), limits=httpx.Limits( max_keepalive_connections=20, max_connections=100, @@ -122,17 +166,29 @@ def _get_client(cls, context: Context) -> httpx.Client: http2=True, verify=cls._get_context(context) ) + cls._client_config = client_config return cls._client @staticmethod - def _do_post(context: Context, url: str, data: dict, headers: Optional[dict] = None) -> httpx.Response: + # pylint: disable=too-many-arguments + def _do_post(*, context: Context, url: str, data: dict, headers: Optional[dict] = None, + operation: str, error_cls: Type[RemoteError] = RemoteError) -> httpx.Response: client = Remote._get_client(context) - return client.post(url, json=data, headers=headers) + return Remote._request( + lambda: client.post(url, json=data, headers=headers), + operation, + error_cls + ) @staticmethod - def _do_get(context: Context, url: str, headers: Optional[dict] = None) -> httpx.Response: + def _do_get(*, context: Context, url: str, headers: Optional[dict] = None, + operation: str, error_cls: Type[RemoteError] = RemoteError) -> httpx.Response: client = Remote._get_client(context) - return client.get(url, headers=headers) + return Remote._request( + lambda: client.get(url, headers=headers), + operation, + error_cls + ) @staticmethod def _get_header(token: Optional[str]) -> dict: @@ -143,7 +199,7 @@ def _get_header(token: Optional[str]) -> dict: @staticmethod def _get_context(context: Context) -> bool | ssl.SSLContext: - cert_path = context.options.cert_path + cert_path = context.options.remote.cert_path if cert_path is None: return True @@ -151,3 +207,24 @@ def _get_context(context: Context) -> bool | ssl.SSLContext: ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain(certfile=cert_path) return ctx + + @staticmethod + def _get_client_config(context: Context) -> tuple[Optional[str], float, float, float, float]: + return ( + context.options.remote.cert_path, + context.options.remote.connect_timeout, + context.options.remote.read_timeout, + context.options.remote.write_timeout, + context.options.remote.pool_timeout + ) + + @staticmethod + def _request( + request: Callable[[], httpx.Response], + operation: str, + error_cls: Type[RemoteError] + ) -> httpx.Response: + try: + return request() + except httpx.RequestError as exc: + raise error_cls(f'[{operation}] remote unavailable') from exc diff --git a/tests/helpers.py b/tests/helpers.py index 239cedc..fe8e0db 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,5 @@ import time +import httpx from typing import Optional from pytest_httpx import HTTPXMock @@ -12,7 +13,7 @@ def given_context(*, domain='Switcher API', component='switcher-client-python', environment=DEFAULT_ENVIRONMENT, - options=ContextOptions()): + options: Optional[ContextOptions] = None): Client.build_context( url=url, api_key=api_key, @@ -22,7 +23,13 @@ def given_context(*, options=options ) -def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]', exp=int(round(time.time() * 1000)), is_reusable=False): +def given_auth( + httpx_mock: HTTPXMock, + status=200, + token: Optional[str]='[token]', + exp=int(round(time.time() * 1000)), + is_reusable=False +): httpx_mock.add_response( url='https://api.switcherapi.com/criteria/auth', method='POST', @@ -31,14 +38,35 @@ def given_auth(httpx_mock: HTTPXMock, status=200, token: Optional[str]='[token]' is_reusable=is_reusable ) -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', + response={}, + show_details=False, + match=None, + match_extensions=None +): httpx_mock.add_response( is_reusable=True, url=f'https://api.switcherapi.com/criteria?showReason={str(show_details).lower()}&key={key}', method='POST', status_code=status, json=response, - match_json=match + match_json=match, + match_extensions=match_extensions + ) + +def given_check_criteria_exception( + httpx_mock: HTTPXMock, + exception: httpx.RequestError, + key='MY_SWITCHER', + show_details=False +): + httpx_mock.add_exception( + exception, + url=f'https://api.switcherapi.com/criteria?showReason={str(show_details).lower()}&key={key}', + method='POST' ) def given_check_switchers(httpx_mock: HTTPXMock, status=200, not_found: Optional[list[str]]=None): @@ -57,6 +85,13 @@ def given_check_health(httpx_mock: HTTPXMock, status=200): status_code=status, ) +def given_check_health_exception(httpx_mock: HTTPXMock, exception: httpx.RequestError): + httpx_mock.add_exception( + exception, + url='https://api.switcherapi.com/check', + method='GET' + ) + def given_check_snapshot_version(httpx_mock: HTTPXMock, status_code=200, version=0, status=False, is_reusable=False): httpx_mock.add_response( url=f'https://api.switcherapi.com/criteria/snapshot_check/{version}', @@ -73,4 +108,4 @@ def given_resolve_snapshot(httpx_mock: HTTPXMock, status_code=200, data=[], is_r status_code=status_code, json={'data': { 'domain': data}}, is_reusable=is_reusable - ) \ No newline at end of file + ) diff --git a/tests/playground/index.py b/tests/playground/index.py index fa36f56..7d6e7f1 100644 --- a/tests/playground/index.py +++ b/tests/playground/index.py @@ -1,6 +1,7 @@ import threading import time import os +from typing import Optional from dotenv import load_dotenv @@ -13,7 +14,7 @@ SWITCHER_KEY = 'CLIENT_PYTHON_FEATURE' LOOP = True -def setup_context(options: ContextOptions = ContextOptions(), environment = DEFAULT_ENVIRONMENT): +def setup_context(options: Optional[ContextOptions] = None, environment = DEFAULT_ENVIRONMENT): Client.build_context( domain='Switcher API', url='https://api.switcherapi.com', @@ -120,4 +121,4 @@ def uc_watch_snapshot(): while LOOP: time.sleep(1) except KeyboardInterrupt: - print("\nStopping...") \ No newline at end of file + print("\nStopping...") diff --git a/tests/test_client_context.py b/tests/test_client_context.py index b75917f..2fe3a97 100644 --- a/tests/test_client_context.py +++ b/tests/test_client_context.py @@ -44,3 +44,16 @@ def test_context_get_switcher_from_cache(): assert switcher1 is switcher2 assert switcher1 is not switcher3 + +def test_build_context_creates_fresh_default_options(): + """ Should create a fresh ContextOptions instance for each default build_context call """ + + Client.build_context(domain='First Domain') + first_options = Client._context.options + first_options.local = True + + Client.build_context(domain='Second Domain') + second_options = Client._context.options + + assert second_options is not first_options + assert second_options.local == False diff --git a/tests/test_switcher_integration.py b/tests/test_switcher_integration.py index 9a7d687..5ae6fdf 100644 --- a/tests/test_switcher_integration.py +++ b/tests/test_switcher_integration.py @@ -1,4 +1,5 @@ import os +from typing import Optional from dotenv import load_dotenv @@ -61,11 +62,11 @@ def test_check_switcher_availability(): # Helpers -def given_context(options=ContextOptions()): +def given_context(options: Optional[ContextOptions] = None): Client.build_context( url='https://api.switcherapi.com', api_key=API_KEY, domain='Switcher API', component='switcher-client-python', options=options - ) \ No newline at end of file + ) diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py index 546ff4e..6485747 100644 --- a/tests/test_switcher_remote.py +++ b/tests/test_switcher_remote.py @@ -1,14 +1,22 @@ import pytest import time +import httpx from unittest.mock import Mock, patch -from tests.helpers import given_context, given_auth, given_check_criteria +from tests.helpers import ( + given_context, + given_auth, + given_check_criteria, + given_check_criteria_exception, + given_check_health_exception +) 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 +from switcher_client.lib.globals.global_context import ContextOptions, RemoteOptions +from switcher_client.lib.remote import Remote async_error = None @@ -131,7 +139,9 @@ def test_remote_with_custom_cert(httpx_mock): # given given_auth(httpx_mock) given_check_criteria(httpx_mock, response={'result': True}) - given_context(options=ContextOptions(cert_path='tests/fixtures/dummy_cert.pem')) + given_context(options=ContextOptions( + remote=RemoteOptions(cert_path='tests/fixtures/dummy_cert.pem') + )) switcher = Client.get_switcher() @@ -236,3 +246,80 @@ def test_remote_err_check_criteria_default_result(httpx_mock): assert feature.result is True assert feature.reason == 'Default result' assert async_error == '[check_criteria] failed with status: 500' + +def test_remote_err_check_criteria_unavailable(httpx_mock): + """ Should raise an exception when the remote criteria endpoint is unavailable """ + + # given + given_auth(httpx_mock) + given_check_criteria_exception(httpx_mock, httpx.ConnectTimeout('timed out'), key='MY_SWITCHER') + given_context() + + switcher = Client.get_switcher() + + # test + with pytest.raises(Exception) as excinfo: + switcher.is_on('MY_SWITCHER') + + assert '[check_criteria] remote unavailable' in str(excinfo.value) + +def test_remote_health_check_unavailable(httpx_mock): + """ Should return false when the remote health endpoint is unavailable """ + + # given + given_check_health_exception(httpx_mock, httpx.ConnectError('connection failed')) + given_context() + + # test + assert not Remote.check_api_health(Client._context) + +def test_remote_client_rebuilds_when_timeout_changes(httpx_mock): + """ Should rebuild the shared remote client when timeout options change """ + + # given + Remote._client = None + Remote._client_config = None + given_auth(httpx_mock) + given_check_criteria(httpx_mock, response={'result': True}, match={'entry': []}, + match_extensions={ + 'timeout': { + 'connect': 0.3, + 'read': 5.0, + 'write': 5.0, + 'pool': 5.0 + } + } + ) + given_auth(httpx_mock) + given_check_criteria(httpx_mock, response={'result': True}, match={'entry': []}, + match_extensions={ + 'timeout': { + 'connect': 1.2, + 'read': 1.3, + 'write': 1.4, + 'pool': 1.5 + } + } + ) + + given_context(options=ContextOptions( + remote=RemoteOptions( + connect_timeout=0.3, + read_timeout=5.0, + write_timeout=5.0, + pool_timeout=5.0 + ) + )) + assert Client.get_switcher().is_on('MY_SWITCHER') + + given_context(options=ContextOptions( + remote=RemoteOptions( + connect_timeout=1.2, + read_timeout=1.3, + write_timeout=1.4, + pool_timeout=1.5 + ) + )) + + # test + assert Client.get_switcher().is_on('MY_SWITCHER') diff --git a/tests/test_switcher_silent_mode.py b/tests/test_switcher_silent_mode.py index 4905c37..4205c77 100644 --- a/tests/test_switcher_silent_mode.py +++ b/tests/test_switcher_silent_mode.py @@ -1,6 +1,16 @@ +import httpx import time -from tests.helpers import given_context, given_auth, given_check_criteria, given_check_health +from tests.helpers import ( + given_check_snapshot_version, + given_context, + given_auth, + given_check_criteria, + given_check_criteria_exception, + given_check_health, + given_resolve_snapshot +) +from tests.test_client_load_snapshot_remote import load_snapshot_fixture from switcher_client import Client from switcher_client.lib.globals.global_context import ContextOptions @@ -34,6 +44,36 @@ def test_silent_mode_for_check_criteria(httpx_mock): assert switcher.is_on('FF2FOR2022') assert async_error is None +def test_silent_mode_for_check_criteria_in_memory_snapshot(httpx_mock): + """ Should use the silent mode when the remote API is not available for check criteria using the in-memory snapshot """ + + options = ContextOptions(**vars(context_options)) + options.snapshot_location = None + options.silent_mode = '1s' + + # given + given_auth(httpx_mock) + given_check_snapshot_version(httpx_mock, version=0, status=False) + given_resolve_snapshot(httpx_mock, data=load_snapshot_fixture('tests/snapshots/default.json')) + given_check_criteria(httpx_mock, key='FF2FOR2022', response={'error': 'Too many requests'}, status=429) + given_check_health(httpx_mock, status=500) + given_context(options=options) + + Client.subscribe_notify_error(lambda error: globals().update(async_error=str(error))) + switcher = Client.get_switcher('FF2FOR2022') + + # test + # assert silent mode being used while registering the error + assert switcher.is_on('FF2FOR2022') + assert async_error == '[check_criteria] failed with status: 429' + + # assert silent mode being used in the next call + time.sleep(1.5) + globals().update(async_error=None) + assert switcher.is_on('FF2FOR2022') + assert async_error is None + + 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 """ @@ -61,3 +101,22 @@ def test_silent_mode_for_check_criteria_restabilished(httpx_mock): globals().update(async_error=None) assert switcher.is_on('FF2FOR2022') assert async_error is None + +def test_silent_mode_for_check_criteria_timeout(httpx_mock): + """ Should use silent mode when the remote criteria endpoint times out """ + + options = ContextOptions(**vars(context_options)) + options.silent_mode = '1s' + + # given + given_auth(httpx_mock) + given_check_criteria_exception(httpx_mock, httpx.ConnectTimeout('timed out'), key='FF2FOR2022') + given_context(options=options) + + globals().update(async_error=None) + Client.subscribe_notify_error(lambda error: globals().update(async_error=str(error))) + switcher = Client.get_switcher('FF2FOR2022') + + # test + assert switcher.is_on('FF2FOR2022') + assert async_error == '[check_criteria] remote unavailable'