From cee124f377ec64b8d065013d90de9471a348424a Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:41:59 -0700 Subject: [PATCH] feat: added remote connection options --- .github/workflows/publish-anaconda.yml | 2 +- .github/workflows/release-test.yml | 2 +- README.md | 8 ++- sonar-project.properties | 2 +- switcher_client/lib/globals/global_context.py | 10 ++++ switcher_client/lib/remote.py | 15 +++-- switcher_client/version.py | 2 +- tests/test_switcher_remote.py | 57 ++++++++++++++++++- 8 files changed, 84 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-anaconda.yml b/.github/workflows/publish-anaconda.yml index 6e9b9f4..9a86a66 100644 --- a/.github/workflows/publish-anaconda.yml +++ b/.github/workflows/publish-anaconda.yml @@ -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: diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 38d427a..569027a 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -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} diff --git a/README.md b/README.md index 657d14a..502dac5 100644 --- a/README.md +++ b/README.md @@ -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 ) ) ) @@ -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 diff --git a/sonar-project.properties b/sonar-project.properties index bfcee5a..c4785c1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -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 diff --git a/switcher_client/lib/globals/global_context.py b/switcher_client/lib/globals/global_context.py index 88231a9..53601bc 100644 --- a/switcher_client/lib/globals/global_context.py +++ b/switcher_client/lib/globals/global_context.py @@ -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 @@ -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: diff --git a/switcher_client/lib/remote.py b/switcher_client/lib/remote.py index 95daf22..d4cfe08 100644 --- a/switcher_client/lib/remote.py +++ b/switcher_client/lib/remote.py @@ -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): @@ -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) @@ -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 diff --git a/switcher_client/version.py b/switcher_client/version.py index 6849410..c68196d 100644 --- a/switcher_client/version.py +++ b/switcher_client/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/tests/test_switcher_remote.py b/tests/test_switcher_remote.py index dc643c5..b06b70f 100644 --- a/tests/test_switcher_remote.py +++ b/tests/test_switcher_remote.py @@ -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 @@ -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) @@ -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) @@ -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