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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
)
)
)

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion switcher_client/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +11,7 @@
'Client',
'Switcher',
'ContextOptions',
'RemoteOptions',
'LoadSnapshotOptions',
'RetryOptions',
'WatchSnapshotCallback',
Expand Down
16 changes: 9 additions & 7 deletions switcher_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = {
Expand Down
32 changes: 26 additions & 6 deletions switcher_client/lib/globals/global_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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, *,
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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):
Expand Down
119 changes: 98 additions & 21 deletions switcher_client/lib/remote.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import Callable, Optional, Type

import json
import ssl
Expand All @@ -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']
Expand All @@ -37,15 +45,25 @@ 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

@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}'
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()
Expand All @@ -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}')
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -143,11 +199,32 @@ 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

ctx = ssl.create_default_context()
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
Loading
Loading