From 8c9ea7cd2400bf6025889812ee494fe3de39a44e Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Sat, 27 Jun 2026 08:29:37 +0100 Subject: [PATCH] feat(client): add configurable timeout and retry settings Add global shade.timeout and shade.max_retries backed by config.py, validate settings on client construction, and resolve per-instance ShadeClient/Gateway overrides with module-level defaults. Closes #5 --- src/shade/__init__.py | 24 ++++- src/shade/config.py | 19 ++++ src/shade/gateway.py | 34 ++++--- src/shade/http.py | 21 ++-- tests/test_client_settings.py | 184 ++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 tests/test_client_settings.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index af2d261..a5d0ced 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -34,11 +34,13 @@ "ShadeError", "SyncHTTPClient", "api_base", + "max_retries", + "timeout", ] class _ShadeModule(ModuleType): - """Module subclass that exposes api_base as a settable attribute backed by config.""" + """Module subclass that exposes config-backed attributes on the shade package.""" @property def api_base(self) -> Optional[str]: @@ -50,5 +52,25 @@ def api_base(self, value: Optional[str]) -> None: from . import config as _config _config.api_base = value + @property + def timeout(self) -> float: + from . import config as _config + return _config.timeout + + @timeout.setter + def timeout(self, value: float) -> None: + from . import config as _config + _config.timeout = value + + @property + def max_retries(self) -> int: + from . import config as _config + return _config.max_retries + + @max_retries.setter + def max_retries(self, value: int) -> None: + from . import config as _config + _config.max_retries = value + sys.modules[__name__].__class__ = _ShadeModule diff --git a/src/shade/config.py b/src/shade/config.py index f29a6af..11ffad2 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -9,6 +9,25 @@ # Set this before creating any client to route all requests to a custom host. api_base: Optional[str] = None +# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries`` +# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``. +DEFAULT_TIMEOUT: float = 30.0 +DEFAULT_MAX_RETRIES: int = 3 +MAX_RETRIES_LIMIT: int = 10 + +timeout: float = DEFAULT_TIMEOUT +max_retries: int = DEFAULT_MAX_RETRIES + + +def validate_client_settings(timeout: float, max_retries: int) -> None: + """Raise ValueError for out-of-range timeout or retry settings.""" + if timeout <= 0: + raise ValueError(f"timeout must be greater than 0, got {timeout!r}") + if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT: + raise ValueError( + f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}" + ) + class Environment(str, Enum): MAINNET = "mainnet" diff --git a/src/shade/gateway.py b/src/shade/gateway.py index ed71b4a..71f431b 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -3,8 +3,8 @@ from typing import Any, Dict, Optional from . import config as _config -from .config import Environment -from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES +from .config import Environment, validate_client_settings +from .http import AsyncHTTPClient, SyncHTTPClient class Gateway: @@ -25,11 +25,13 @@ class Gateway: Intended for development and testing only. base_url : str Deprecated. Prefer ``api_base``. - max_retries : int - Number of automatic retries on HTTP 429. Defaults to - ``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable. - timeout : float - Per-request socket timeout in seconds. + max_retries : int, optional + Number of automatic retries on HTTP 429 and transient failures. + Defaults to the module-level ``shade.max_retries`` (3). Set to ``0`` + to disable auto-retry. + timeout : float, optional + Per-request socket timeout in seconds. Defaults to the module-level + ``shade.timeout`` (30.0). """ def __init__( @@ -38,14 +40,20 @@ def __init__( environment: Environment = Environment.MAINNET, api_base: Optional[str] = None, base_url: str = "", - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float = 30.0, + max_retries: Optional[int] = None, + timeout: Optional[float] = None, ) -> None: if not api_key: raise ValueError("api_key must be a non-empty string") self.api_key = api_key self.environment = environment + resolved_max_retries = ( + _config.max_retries if max_retries is None else max_retries + ) + resolved_timeout = _config.timeout if timeout is None else timeout + validate_client_settings(resolved_timeout, resolved_max_retries) + # Resolution order: explicit api_base > module-level shade.api_base # > legacy base_url > environment URL resolved = api_base or _config.api_base or base_url or environment.base_url @@ -54,14 +62,14 @@ def __init__( self._http = SyncHTTPClient( base_url=self._base_url, api_key=api_key, - max_retries=max_retries, - timeout=timeout, + max_retries=resolved_max_retries, + timeout=resolved_timeout, ) self._async_http = AsyncHTTPClient( base_url=self._base_url, api_key=api_key, - max_retries=max_retries, - timeout=timeout, + max_retries=resolved_max_retries, + timeout=resolved_timeout, ) # ------------------------------------------------------------------ diff --git a/src/shade/http.py b/src/shade/http.py index b05ab26..5118de9 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -19,6 +19,8 @@ import urllib.request from typing import Any, Dict, Optional, Tuple +from .config import DEFAULT_MAX_RETRIES, validate_client_settings +from . import config as _config from .errors import ( AuthenticationError, HTTPError, @@ -39,7 +41,6 @@ # Constants # --------------------------------------------------------------------------- -DEFAULT_MAX_RETRIES: int = 3 _BASE_BACKOFF: float = 1.0 # seconds for exponential back-off base _MAX_BACKOFF: float = 60.0 # cap individual wait at 60 s @@ -248,14 +249,15 @@ def __init__( self, base_url: str, api_key: str, - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float = 30.0, + max_retries: Optional[int] = None, + timeout: Optional[float] = None, ) -> None: _validate_base_url(base_url) self.base_url = base_url.rstrip("/") self.api_key = api_key - self.max_retries = max_retries - self.timeout = timeout + self.max_retries = _config.max_retries if max_retries is None else max_retries + self.timeout = _config.timeout if timeout is None else timeout + validate_client_settings(self.timeout, self.max_retries) def _build_request( self, method: str, path: str, payload: Optional[Dict[str, Any]] @@ -347,14 +349,15 @@ def __init__( self, base_url: str, api_key: str, - max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float = 30.0, + max_retries: Optional[int] = None, + timeout: Optional[float] = None, ) -> None: _validate_base_url(base_url) self.base_url = base_url.rstrip("/") self.api_key = api_key - self.max_retries = max_retries - self.timeout = timeout + self.max_retries = _config.max_retries if max_retries is None else max_retries + self.timeout = _config.timeout if timeout is None else timeout + validate_client_settings(self.timeout, self.max_retries) async def request( self, diff --git a/tests/test_client_settings.py b/tests/test_client_settings.py new file mode 100644 index 0000000..cff56e7 --- /dev/null +++ b/tests/test_client_settings.py @@ -0,0 +1,184 @@ +""" +Tests for configurable timeout and retry settings (issue #5). + +Covers: +* shade.timeout and shade.max_retries module-level settings +* ShadeClient/Gateway per-instance overrides +* Validation of out-of-range values +* Timeout passed through to the HTTP transport layer +* max_retries=0 disables retries entirely +""" +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +import shade +from shade import Gateway, ShadeClient +from shade import config as _config +from shade.config import validate_client_settings +from shade.errors import RateLimitError +from shade.http import SyncHTTPClient + + +@pytest.fixture(autouse=True) +def _reset_client_settings(): + original_timeout = _config.timeout + original_max_retries = _config.max_retries + yield + _config.timeout = original_timeout + _config.max_retries = original_max_retries + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +class TestValidateClientSettings: + @pytest.mark.parametrize("timeout", [0, -1.0, -0.1]) + def test_non_positive_timeout_raises(self, timeout): + with pytest.raises(ValueError, match="timeout must be greater than 0"): + validate_client_settings(timeout, 3) + + @pytest.mark.parametrize("max_retries", [-1, 11, 100]) + def test_out_of_range_max_retries_raises(self, max_retries): + with pytest.raises(ValueError, match="max_retries must be between 0 and 10"): + validate_client_settings(30.0, max_retries) + + def test_boundary_values_are_valid(self): + validate_client_settings(0.1, 0) + validate_client_settings(30.0, 10) + + +# --------------------------------------------------------------------------- +# Module-level shade.timeout / shade.max_retries +# --------------------------------------------------------------------------- + +class TestModuleLevelClientSettings: + def test_defaults(self): + assert shade.timeout == 30.0 + assert shade.max_retries == 3 + + def test_assignment_is_readable(self): + shade.timeout = 15.0 + shade.max_retries = 5 + assert shade.timeout == 15.0 + assert shade.max_retries == 5 + + def test_assignment_updates_config(self): + shade.timeout = 12.0 + shade.max_retries = 2 + assert _config.timeout == 12.0 + assert _config.max_retries == 2 + + def test_used_when_no_per_client_override(self): + shade.timeout = 12.0 + shade.max_retries = 2 + client = ShadeClient(api_key="test-key") + assert client._http.timeout == 12.0 + assert client._http.max_retries == 2 + assert client._async_http.timeout == 12.0 + assert client._async_http.max_retries == 2 + + +# --------------------------------------------------------------------------- +# Per-client overrides +# --------------------------------------------------------------------------- + +class TestPerClientSettings: + def test_timeout_override(self): + shade.timeout = 30.0 + client = ShadeClient(api_key="test-key", timeout=5.0) + assert client._http.timeout == 5.0 + assert client._async_http.timeout == 5.0 + + def test_max_retries_override(self): + shade.max_retries = 3 + client = ShadeClient(api_key="test-key", max_retries=0) + assert client._http.max_retries == 0 + assert client._async_http.max_retries == 0 + + def test_per_client_beats_module_level(self): + shade.timeout = 30.0 + shade.max_retries = 3 + client = ShadeClient(api_key="test-key", timeout=5.0, max_retries=1) + assert client._http.timeout == 5.0 + assert client._http.max_retries == 1 + + def test_invalid_timeout_on_client_raises(self): + with pytest.raises(ValueError, match="timeout must be greater than 0"): + ShadeClient(api_key="test-key", timeout=-1.0) + + def test_invalid_max_retries_on_client_raises(self): + with pytest.raises(ValueError, match="max_retries must be between 0 and 10"): + ShadeClient(api_key="test-key", max_retries=11) + + +# --------------------------------------------------------------------------- +# Acceptance criteria: timeout and retry behaviour +# --------------------------------------------------------------------------- + +class TestTimeoutBehaviour: + def test_shade_client_timeout_passed_to_urlopen(self): + client = SyncHTTPClient( + base_url="https://api.example.com", + api_key="test-key", + timeout=5.0, + ) + + with patch.object(client, "_execute") as mock_execute: + mock_execute.return_value = (200, {}, b'{"ok": true}') + client.request("GET", "/payments") + + mock_execute.assert_called_once() + req = mock_execute.call_args[0][0] + with patch("urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value.__enter__.return_value.status = 200 + mock_urlopen.return_value.__enter__.return_value.headers = {} + mock_urlopen.return_value.__enter__.return_value.read.return_value = b"{}" + client._execute(req) + mock_urlopen.assert_called_once_with(req, timeout=5.0) + + +class TestMaxRetriesBehaviour: + def _fake_429_body(self) -> bytes: + return json.dumps({"error": {"message": "rate limit"}}).encode() + + def test_max_retries_zero_disables_retries(self): + client = SyncHTTPClient( + base_url="https://api.example.com", + api_key="test-key", + max_retries=0, + ) + + def fake_execute(req): + return 429, {"Retry-After": "3"}, self._fake_429_body() + + with patch.object(client, "_execute", side_effect=fake_execute), patch( + "time.sleep" + ) as mock_sleep: + with pytest.raises(RateLimitError): + client.request("POST", "/payments", {}) + + mock_sleep.assert_not_called() + + def test_shade_client_max_retries_zero_disables_retries(self): + client = ShadeClient(api_key="test-key", max_retries=0) + + def fake_execute(req): + return 429, {"Retry-After": "3"}, self._fake_429_body() + + with patch.object(client._http, "_execute", side_effect=fake_execute), patch( + "time.sleep" + ) as mock_sleep: + with pytest.raises(RateLimitError): + client._http.request("POST", "/payments", {}) + + mock_sleep.assert_not_called() + + +class TestShadeClientAlias: + def test_shade_client_is_gateway(self): + assert ShadeClient is Gateway