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: 23 additions & 1 deletion src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
19 changes: 19 additions & 0 deletions src/shade/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 21 additions & 13 deletions src/shade/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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__(
Expand All @@ -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
Expand All @@ -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,
)

# ------------------------------------------------------------------
Expand Down
21 changes: 12 additions & 9 deletions src/shade/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions tests/test_client_settings.py
Original file line number Diff line number Diff line change
@@ -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
Loading