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 .github/workflows/publish-anaconda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Package version to publish (e.g. 1.1.0). Leave empty to read from version.py'
description: 'Package version to publish (e.g. 1.2.0). Leave empty to read from version.py'
required: false

jobs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:

- name: Build conda package
shell: bash -el {0}
run: conda build --no-test conda.recipe/ --output-folder dist/ -c conda-forge
run: conda-build --no-test conda.recipe/ --output-folder dist/ -c conda-forge

- name: Publish distribution to Anaconda.org
shell: bash -el {0}
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ Client.build_context(
connect_timeout=0.3,
read_timeout=5.0,
write_timeout=5.0,
pool_timeout=5.0
pool_timeout=5.0,
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=30.0
)
)
)
Expand Down Expand Up @@ -207,6 +210,9 @@ switcher = Client.get_switcher()
| `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` |
| `max_keepalive_connections` | `int` | Max number of idle keep-alive connections to maintain | `20` |
| `max_connections` | `int` | Max number of concurrent connections allowed | `100` |
| `keepalive_expiry` | `float` | Max seconds a keep-alive connection can remain idle | `30.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
Expand Down
2 changes: 1 addition & 1 deletion sonar-project.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
sonar.projectKey=switcherapi_switcher-client-py
sonar.projectName=switcher-client-py
sonar.organization=switcherapi
sonar.projectVersion=1.1.0
sonar.projectVersion=1.2.0
sonar.links.homepage=https://github.com/switcherapi/switcher-client-py

sonar.sources=switcher_client
Expand Down
10 changes: 10 additions & 0 deletions switcher_client/lib/globals/global_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
DEFAULT_REMOTE_READ_TIMEOUT = 5.0
DEFAULT_REMOTE_WRITE_TIMEOUT = 5.0
DEFAULT_REMOTE_POOL_TIMEOUT = 5.0
DEFAULT_REMOTE_MAX_KEEPALIVE_CONNECTIONS = 20
DEFAULT_REMOTE_MAX_CONNECTIONS = 100
DEFAULT_REMOTE_KEEPALIVE_EXPIRY = 30.0
DEFAULT_REMOTE_CERT_PATH = None
DEFAULT_REMOTE_AUTO_RENEW_TOKEN = False
DEFAULT_TEST_MODE = False
Expand All @@ -27,13 +30,20 @@ class RemoteOptions:
: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
:param max_keepalive_connections: Max number of idle keep-alive connections to maintain
:param max_connections: Max number of concurrent connections allowed
:param keepalive_expiry: Max time in seconds a keep-alive connection can remain idle
"""
# pylint: disable=too-many-instance-attributes
cert_path: Optional[str] = DEFAULT_REMOTE_CERT_PATH
auto_renew_token: bool = DEFAULT_REMOTE_AUTO_RENEW_TOKEN
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
max_keepalive_connections: int = DEFAULT_REMOTE_MAX_KEEPALIVE_CONNECTIONS
max_connections: int = DEFAULT_REMOTE_MAX_CONNECTIONS
keepalive_expiry: float = DEFAULT_REMOTE_KEEPALIVE_EXPIRY

@dataclass
class ContextOptions:
Expand Down
15 changes: 9 additions & 6 deletions switcher_client/lib/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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
_client_config: Optional[tuple[Optional[str], float, float, float, float, int, int, float]] = None

@staticmethod
def auth(context: Context):
Expand Down Expand Up @@ -159,9 +159,9 @@ def _get_client(cls, context: Context) -> httpx.Client:
pool=context.options.remote.pool_timeout
),
limits=httpx.Limits(
max_keepalive_connections=20,
max_connections=100,
keepalive_expiry=30.0
max_keepalive_connections=context.options.remote.max_keepalive_connections,
max_connections=context.options.remote.max_connections,
keepalive_expiry=context.options.remote.keepalive_expiry
),
http2=True,
verify=cls._get_context(context)
Expand Down Expand Up @@ -209,13 +209,16 @@ def _get_context(context: Context) -> bool | ssl.SSLContext:
return ctx

@staticmethod
def _get_client_config(context: Context) -> tuple[Optional[str], float, float, float, float]:
def _get_client_config(context: Context) -> tuple[Optional[str], float, float, float, float, int, int, 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
context.options.remote.pool_timeout,
context.options.remote.max_keepalive_connections,
context.options.remote.max_connections,
context.options.remote.keepalive_expiry
)

@staticmethod
Expand Down
2 changes: 1 addition & 1 deletion switcher_client/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.1.0"
__version__ = "1.2.0"
57 changes: 54 additions & 3 deletions tests/test_switcher_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
given_check_health_exception
)

from switcher_client.errors import RemoteAuthError
from switcher_client import Client
from switcher_client.errors import RemoteAuthError, RemoteCriteriaError
from switcher_client.lib.globals.global_auth import GlobalAuth
from switcher_client.lib.globals.global_context import ContextOptions, RemoteOptions
from switcher_client.lib.remote import Remote
Expand Down Expand Up @@ -301,7 +301,7 @@ def test_remote_err_check_criteria(httpx_mock):
switcher = Client.get_switcher()

# test
with pytest.raises(Exception) as excinfo:
with pytest.raises(RemoteCriteriaError) as excinfo:
switcher.is_on('MY_SWITCHER')

assert '[check_criteria] failed with status: 500' in str(excinfo.value)
Expand Down Expand Up @@ -335,7 +335,7 @@ def test_remote_err_check_criteria_unavailable(httpx_mock):
switcher = Client.get_switcher()

# test
with pytest.raises(Exception) as excinfo:
with pytest.raises(RemoteCriteriaError) as excinfo:
switcher.is_on('MY_SWITCHER')

assert '[check_criteria] remote unavailable' in str(excinfo.value)
Expand Down Expand Up @@ -400,3 +400,54 @@ def test_remote_client_rebuilds_when_timeout_changes(httpx_mock):

# test
assert Client.get_switcher().is_on('MY_SWITCHER')

def test_remote_client_uses_default_connection_limits():
""" Should build the shared remote client using the default connection limit options """

# given
Remote._client = None
Remote._client_config = None
given_context()

# test
client = Remote._get_client(Client._context)
pool = client._transport._pool # type: ignore
assert pool._max_keepalive_connections == 20
assert pool._max_connections == 100
assert pool._keepalive_expiry == 30.0

def test_remote_client_rebuilds_when_connection_limits_change():
""" Should rebuild the shared remote client when connection limit options change """

# given
Remote._client = None
Remote._client_config = None
given_context(options=ContextOptions(
remote=RemoteOptions(
max_keepalive_connections=5,
max_connections=10,
keepalive_expiry=15.0
)
))

# test
client = Remote._get_client(Client._context)
pool = client._transport._pool # type: ignore
assert pool._max_keepalive_connections == 5
assert pool._max_connections == 10
assert pool._keepalive_expiry == 15.0

given_context(options=ContextOptions(
remote=RemoteOptions(
max_keepalive_connections=8,
max_connections=16,
keepalive_expiry=25.0
)
))

rebuilt_client = Remote._get_client(Client._context)
assert rebuilt_client is not client
rebuilt_pool = rebuilt_client._transport._pool # type: ignore
assert rebuilt_pool._max_keepalive_connections == 8
assert rebuilt_pool._max_connections == 16
assert rebuilt_pool._keepalive_expiry == 25.0