Skip to content

Commit 8c9ea7c

Browse files
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
1 parent 3ad7931 commit 8c9ea7c

5 files changed

Lines changed: 259 additions & 23 deletions

File tree

src/shade/__init__.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
"ShadeError",
3535
"SyncHTTPClient",
3636
"api_base",
37+
"max_retries",
38+
"timeout",
3739
]
3840

3941

4042
class _ShadeModule(ModuleType):
41-
"""Module subclass that exposes api_base as a settable attribute backed by config."""
43+
"""Module subclass that exposes config-backed attributes on the shade package."""
4244

4345
@property
4446
def api_base(self) -> Optional[str]:
@@ -50,5 +52,25 @@ def api_base(self, value: Optional[str]) -> None:
5052
from . import config as _config
5153
_config.api_base = value
5254

55+
@property
56+
def timeout(self) -> float:
57+
from . import config as _config
58+
return _config.timeout
59+
60+
@timeout.setter
61+
def timeout(self, value: float) -> None:
62+
from . import config as _config
63+
_config.timeout = value
64+
65+
@property
66+
def max_retries(self) -> int:
67+
from . import config as _config
68+
return _config.max_retries
69+
70+
@max_retries.setter
71+
def max_retries(self, value: int) -> None:
72+
from . import config as _config
73+
_config.max_retries = value
74+
5375

5476
sys.modules[__name__].__class__ = _ShadeModule

src/shade/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@
99
# Set this before creating any client to route all requests to a custom host.
1010
api_base: Optional[str] = None
1111

12+
# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries``
13+
# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``.
14+
DEFAULT_TIMEOUT: float = 30.0
15+
DEFAULT_MAX_RETRIES: int = 3
16+
MAX_RETRIES_LIMIT: int = 10
17+
18+
timeout: float = DEFAULT_TIMEOUT
19+
max_retries: int = DEFAULT_MAX_RETRIES
20+
21+
22+
def validate_client_settings(timeout: float, max_retries: int) -> None:
23+
"""Raise ValueError for out-of-range timeout or retry settings."""
24+
if timeout <= 0:
25+
raise ValueError(f"timeout must be greater than 0, got {timeout!r}")
26+
if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT:
27+
raise ValueError(
28+
f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}"
29+
)
30+
1231

1332
class Environment(str, Enum):
1433
MAINNET = "mainnet"

src/shade/gateway.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from typing import Any, Dict, Optional
44

55
from . import config as _config
6-
from .config import Environment
7-
from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES
6+
from .config import Environment, validate_client_settings
7+
from .http import AsyncHTTPClient, SyncHTTPClient
88

99

1010
class Gateway:
@@ -25,11 +25,13 @@ class Gateway:
2525
Intended for development and testing only.
2626
base_url : str
2727
Deprecated. Prefer ``api_base``.
28-
max_retries : int
29-
Number of automatic retries on HTTP 429. Defaults to
30-
``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable.
31-
timeout : float
32-
Per-request socket timeout in seconds.
28+
max_retries : int, optional
29+
Number of automatic retries on HTTP 429 and transient failures.
30+
Defaults to the module-level ``shade.max_retries`` (3). Set to ``0``
31+
to disable auto-retry.
32+
timeout : float, optional
33+
Per-request socket timeout in seconds. Defaults to the module-level
34+
``shade.timeout`` (30.0).
3335
"""
3436

3537
def __init__(
@@ -38,14 +40,20 @@ def __init__(
3840
environment: Environment = Environment.MAINNET,
3941
api_base: Optional[str] = None,
4042
base_url: str = "",
41-
max_retries: int = DEFAULT_MAX_RETRIES,
42-
timeout: float = 30.0,
43+
max_retries: Optional[int] = None,
44+
timeout: Optional[float] = None,
4345
) -> None:
4446
if not api_key:
4547
raise ValueError("api_key must be a non-empty string")
4648
self.api_key = api_key
4749
self.environment = environment
4850

51+
resolved_max_retries = (
52+
_config.max_retries if max_retries is None else max_retries
53+
)
54+
resolved_timeout = _config.timeout if timeout is None else timeout
55+
validate_client_settings(resolved_timeout, resolved_max_retries)
56+
4957
# Resolution order: explicit api_base > module-level shade.api_base
5058
# > legacy base_url > environment URL
5159
resolved = api_base or _config.api_base or base_url or environment.base_url
@@ -54,14 +62,14 @@ def __init__(
5462
self._http = SyncHTTPClient(
5563
base_url=self._base_url,
5664
api_key=api_key,
57-
max_retries=max_retries,
58-
timeout=timeout,
65+
max_retries=resolved_max_retries,
66+
timeout=resolved_timeout,
5967
)
6068
self._async_http = AsyncHTTPClient(
6169
base_url=self._base_url,
6270
api_key=api_key,
63-
max_retries=max_retries,
64-
timeout=timeout,
71+
max_retries=resolved_max_retries,
72+
timeout=resolved_timeout,
6573
)
6674

6775
# ------------------------------------------------------------------

src/shade/http.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import urllib.request
2020
from typing import Any, Dict, Optional, Tuple
2121

22+
from .config import DEFAULT_MAX_RETRIES, validate_client_settings
23+
from . import config as _config
2224
from .errors import (
2325
AuthenticationError,
2426
HTTPError,
@@ -39,7 +41,6 @@
3941
# Constants
4042
# ---------------------------------------------------------------------------
4143

42-
DEFAULT_MAX_RETRIES: int = 3
4344
_BASE_BACKOFF: float = 1.0 # seconds for exponential back-off base
4445
_MAX_BACKOFF: float = 60.0 # cap individual wait at 60 s
4546

@@ -248,14 +249,15 @@ def __init__(
248249
self,
249250
base_url: str,
250251
api_key: str,
251-
max_retries: int = DEFAULT_MAX_RETRIES,
252-
timeout: float = 30.0,
252+
max_retries: Optional[int] = None,
253+
timeout: Optional[float] = None,
253254
) -> None:
254255
_validate_base_url(base_url)
255256
self.base_url = base_url.rstrip("/")
256257
self.api_key = api_key
257-
self.max_retries = max_retries
258-
self.timeout = timeout
258+
self.max_retries = _config.max_retries if max_retries is None else max_retries
259+
self.timeout = _config.timeout if timeout is None else timeout
260+
validate_client_settings(self.timeout, self.max_retries)
259261

260262
def _build_request(
261263
self, method: str, path: str, payload: Optional[Dict[str, Any]]
@@ -347,14 +349,15 @@ def __init__(
347349
self,
348350
base_url: str,
349351
api_key: str,
350-
max_retries: int = DEFAULT_MAX_RETRIES,
351-
timeout: float = 30.0,
352+
max_retries: Optional[int] = None,
353+
timeout: Optional[float] = None,
352354
) -> None:
353355
_validate_base_url(base_url)
354356
self.base_url = base_url.rstrip("/")
355357
self.api_key = api_key
356-
self.max_retries = max_retries
357-
self.timeout = timeout
358+
self.max_retries = _config.max_retries if max_retries is None else max_retries
359+
self.timeout = _config.timeout if timeout is None else timeout
360+
validate_client_settings(self.timeout, self.max_retries)
358361

359362
async def request(
360363
self,

tests/test_client_settings.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
Tests for configurable timeout and retry settings (issue #5).
3+
4+
Covers:
5+
* shade.timeout and shade.max_retries module-level settings
6+
* ShadeClient/Gateway per-instance overrides
7+
* Validation of out-of-range values
8+
* Timeout passed through to the HTTP transport layer
9+
* max_retries=0 disables retries entirely
10+
"""
11+
from __future__ import annotations
12+
13+
import json
14+
from unittest.mock import patch
15+
16+
import pytest
17+
18+
import shade
19+
from shade import Gateway, ShadeClient
20+
from shade import config as _config
21+
from shade.config import validate_client_settings
22+
from shade.errors import RateLimitError
23+
from shade.http import SyncHTTPClient
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def _reset_client_settings():
28+
original_timeout = _config.timeout
29+
original_max_retries = _config.max_retries
30+
yield
31+
_config.timeout = original_timeout
32+
_config.max_retries = original_max_retries
33+
34+
35+
# ---------------------------------------------------------------------------
36+
# Validation
37+
# ---------------------------------------------------------------------------
38+
39+
class TestValidateClientSettings:
40+
@pytest.mark.parametrize("timeout", [0, -1.0, -0.1])
41+
def test_non_positive_timeout_raises(self, timeout):
42+
with pytest.raises(ValueError, match="timeout must be greater than 0"):
43+
validate_client_settings(timeout, 3)
44+
45+
@pytest.mark.parametrize("max_retries", [-1, 11, 100])
46+
def test_out_of_range_max_retries_raises(self, max_retries):
47+
with pytest.raises(ValueError, match="max_retries must be between 0 and 10"):
48+
validate_client_settings(30.0, max_retries)
49+
50+
def test_boundary_values_are_valid(self):
51+
validate_client_settings(0.1, 0)
52+
validate_client_settings(30.0, 10)
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Module-level shade.timeout / shade.max_retries
57+
# ---------------------------------------------------------------------------
58+
59+
class TestModuleLevelClientSettings:
60+
def test_defaults(self):
61+
assert shade.timeout == 30.0
62+
assert shade.max_retries == 3
63+
64+
def test_assignment_is_readable(self):
65+
shade.timeout = 15.0
66+
shade.max_retries = 5
67+
assert shade.timeout == 15.0
68+
assert shade.max_retries == 5
69+
70+
def test_assignment_updates_config(self):
71+
shade.timeout = 12.0
72+
shade.max_retries = 2
73+
assert _config.timeout == 12.0
74+
assert _config.max_retries == 2
75+
76+
def test_used_when_no_per_client_override(self):
77+
shade.timeout = 12.0
78+
shade.max_retries = 2
79+
client = ShadeClient(api_key="test-key")
80+
assert client._http.timeout == 12.0
81+
assert client._http.max_retries == 2
82+
assert client._async_http.timeout == 12.0
83+
assert client._async_http.max_retries == 2
84+
85+
86+
# ---------------------------------------------------------------------------
87+
# Per-client overrides
88+
# ---------------------------------------------------------------------------
89+
90+
class TestPerClientSettings:
91+
def test_timeout_override(self):
92+
shade.timeout = 30.0
93+
client = ShadeClient(api_key="test-key", timeout=5.0)
94+
assert client._http.timeout == 5.0
95+
assert client._async_http.timeout == 5.0
96+
97+
def test_max_retries_override(self):
98+
shade.max_retries = 3
99+
client = ShadeClient(api_key="test-key", max_retries=0)
100+
assert client._http.max_retries == 0
101+
assert client._async_http.max_retries == 0
102+
103+
def test_per_client_beats_module_level(self):
104+
shade.timeout = 30.0
105+
shade.max_retries = 3
106+
client = ShadeClient(api_key="test-key", timeout=5.0, max_retries=1)
107+
assert client._http.timeout == 5.0
108+
assert client._http.max_retries == 1
109+
110+
def test_invalid_timeout_on_client_raises(self):
111+
with pytest.raises(ValueError, match="timeout must be greater than 0"):
112+
ShadeClient(api_key="test-key", timeout=-1.0)
113+
114+
def test_invalid_max_retries_on_client_raises(self):
115+
with pytest.raises(ValueError, match="max_retries must be between 0 and 10"):
116+
ShadeClient(api_key="test-key", max_retries=11)
117+
118+
119+
# ---------------------------------------------------------------------------
120+
# Acceptance criteria: timeout and retry behaviour
121+
# ---------------------------------------------------------------------------
122+
123+
class TestTimeoutBehaviour:
124+
def test_shade_client_timeout_passed_to_urlopen(self):
125+
client = SyncHTTPClient(
126+
base_url="https://api.example.com",
127+
api_key="test-key",
128+
timeout=5.0,
129+
)
130+
131+
with patch.object(client, "_execute") as mock_execute:
132+
mock_execute.return_value = (200, {}, b'{"ok": true}')
133+
client.request("GET", "/payments")
134+
135+
mock_execute.assert_called_once()
136+
req = mock_execute.call_args[0][0]
137+
with patch("urllib.request.urlopen") as mock_urlopen:
138+
mock_urlopen.return_value.__enter__.return_value.status = 200
139+
mock_urlopen.return_value.__enter__.return_value.headers = {}
140+
mock_urlopen.return_value.__enter__.return_value.read.return_value = b"{}"
141+
client._execute(req)
142+
mock_urlopen.assert_called_once_with(req, timeout=5.0)
143+
144+
145+
class TestMaxRetriesBehaviour:
146+
def _fake_429_body(self) -> bytes:
147+
return json.dumps({"error": {"message": "rate limit"}}).encode()
148+
149+
def test_max_retries_zero_disables_retries(self):
150+
client = SyncHTTPClient(
151+
base_url="https://api.example.com",
152+
api_key="test-key",
153+
max_retries=0,
154+
)
155+
156+
def fake_execute(req):
157+
return 429, {"Retry-After": "3"}, self._fake_429_body()
158+
159+
with patch.object(client, "_execute", side_effect=fake_execute), patch(
160+
"time.sleep"
161+
) as mock_sleep:
162+
with pytest.raises(RateLimitError):
163+
client.request("POST", "/payments", {})
164+
165+
mock_sleep.assert_not_called()
166+
167+
def test_shade_client_max_retries_zero_disables_retries(self):
168+
client = ShadeClient(api_key="test-key", max_retries=0)
169+
170+
def fake_execute(req):
171+
return 429, {"Retry-After": "3"}, self._fake_429_body()
172+
173+
with patch.object(client._http, "_execute", side_effect=fake_execute), patch(
174+
"time.sleep"
175+
) as mock_sleep:
176+
with pytest.raises(RateLimitError):
177+
client._http.request("POST", "/payments", {})
178+
179+
mock_sleep.assert_not_called()
180+
181+
182+
class TestShadeClientAlias:
183+
def test_shade_client_is_gateway(self):
184+
assert ShadeClient is Gateway

0 commit comments

Comments
 (0)