Skip to content

Commit 0f7b282

Browse files
authored
Merge branch 'main' into fix/13-add-request-response-debug-logging
2 parents 7e048a4 + 3ad7931 commit 0f7b282

7 files changed

Lines changed: 498 additions & 19 deletions

File tree

src/shade/__init__.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import sys
2+
from types import ModuleType
3+
from typing import Optional
4+
15
from .client import ShadeClient
2-
from .config import config
6+
from .config import config, Environment
37
from .gateway import Gateway
48
from .http import AsyncHTTPClient, SyncHTTPClient
59
from .errors import (
@@ -14,9 +18,13 @@
1418

1519
__version__ = "0.1.0"
1620

21+
# ShadeClient is an alias for Gateway.
22+
ShadeClient = Gateway
23+
1724
__all__ = [
1825
"AsyncHTTPClient",
1926
"AuthenticationError",
27+
"Environment",
2028
"Gateway",
2129
"HTTPError",
2230
"InvalidRequestError",
@@ -27,4 +35,22 @@
2735
"ShadeError",
2836
"SyncHTTPClient",
2937
"config",
38+
"api_base",
3039
]
40+
41+
42+
class _ShadeModule(ModuleType):
43+
"""Module subclass that exposes api_base as a settable attribute backed by config."""
44+
45+
@property
46+
def api_base(self) -> Optional[str]:
47+
from . import config as _config
48+
return _config.api_base
49+
50+
@api_base.setter
51+
def api_base(self, value: Optional[str]) -> None:
52+
from . import config as _config
53+
_config.api_base = value
54+
55+
56+
sys.modules[__name__].__class__ = _ShadeModule

src/shade/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,39 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from typing import Optional
5+
6+
from stellar_sdk import Network
7+
8+
# Module-level API base URL override. Intended for development and testing only.
9+
# Set this before creating any client to route all requests to a custom host.
10+
api_base: Optional[str] = None
11+
112
class Config:
213
"""Global SDK configuration."""
314

415
debug: bool = False
516

617

18+
class Environment(str, Enum):
19+
MAINNET = "mainnet"
20+
TESTNET = "testnet"
21+
22+
@property
23+
def base_url(self) -> str:
24+
_urls: dict[str, str] = {
25+
"mainnet": "https://api.shadeprotocol.io/v1",
26+
"testnet": "https://testnet.api.shadeprotocol.io/v1",
27+
}
28+
return _urls[self.value]
29+
30+
@property
31+
def network_passphrase(self) -> str:
32+
_passphrases: dict[str, str] = {
33+
"mainnet": Network.PUBLIC_NETWORK_PASSPHRASE,
34+
"testnet": Network.TESTNET_NETWORK_PASSPHRASE,
35+
}
36+
return _passphrases[self.value]
37+
38+
739
config = Config()

src/shade/gateway.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Any, Dict, Optional
44

5+
from . import config as _config
6+
from .config import Environment
57
from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES
68

79

@@ -13,28 +15,42 @@ class Gateway:
1315
----------
1416
api_key : str
1517
Your Shade API key.
18+
environment : Environment
19+
Controls the Stellar network passphrase and the default API URL.
20+
Defaults to ``Environment.MAINNET``.
21+
api_base : str, optional
22+
Override the API host for this client (useful for local dev or staging).
23+
Takes precedence over the module-level ``shade.api_base`` and the
24+
URL derived from ``environment``. Trailing slashes are trimmed.
25+
Intended for development and testing only.
1626
base_url : str
17-
Override the default API base URL (useful for testing).
27+
Deprecated. Prefer ``api_base``.
1828
max_retries : int
1929
Number of automatic retries on HTTP 429. Defaults to
2030
``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable.
2131
timeout : float
2232
Per-request socket timeout in seconds.
2333
"""
2434

25-
_DEFAULT_BASE_URL = "https://api.shadeprotocol.io/v1"
26-
2735
def __init__(
2836
self,
2937
api_key: str = "",
38+
environment: Environment = Environment.MAINNET,
39+
api_base: Optional[str] = None,
3040
base_url: str = "",
3141
max_retries: int = DEFAULT_MAX_RETRIES,
3242
timeout: float = 30.0,
3343
) -> None:
3444
if not api_key:
3545
raise ValueError("api_key must be a non-empty string")
3646
self.api_key = api_key
37-
self._base_url = base_url or self._DEFAULT_BASE_URL
47+
self.environment = environment
48+
49+
# Resolution order: explicit api_base > module-level shade.api_base
50+
# > legacy base_url > environment URL
51+
resolved = api_base or _config.api_base or base_url or environment.base_url
52+
self._base_url = resolved.rstrip("/")
53+
3854
self._http = SyncHTTPClient(
3955
base_url=self._base_url,
4056
api_key=api_key,
@@ -86,4 +102,4 @@ async def process_payment_async(
86102
"POST",
87103
"/payments",
88104
{"amount": amount, "currency": currency},
89-
)
105+
)

src/shade/http.py

Lines changed: 163 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,30 @@
1010
from __future__ import annotations
1111

1212
import json
13+
import logging
1314
import math
15+
import random
1416
import time
1517
import urllib.error
1618
import urllib.parse
1719
import urllib.request
1820
from typing import Any, Dict, Optional, Tuple
1921

20-
from .errors import HTTPError, RateLimitError
22+
from .errors import (
23+
AuthenticationError,
24+
HTTPError,
25+
InvalidRequestError,
26+
NetworkError,
27+
NotFoundError,
28+
RateLimitError,
29+
)
30+
31+
logger = logging.getLogger(__name__)
32+
33+
try: # pragma: no cover - optional dependency
34+
import httpx
35+
except ImportError: # pragma: no cover - optional dependency
36+
httpx = None
2137

2238
# ---------------------------------------------------------------------------
2339
# Constants
@@ -62,6 +78,72 @@ def _backoff_seconds(attempt: int) -> float:
6278
return min(_BASE_BACKOFF * math.pow(2, attempt), _MAX_BACKOFF)
6379

6480

81+
def _retry_delay(attempt: int, base_delay: float) -> float:
82+
"""Return a capped exponential delay with randomized jitter."""
83+
return min(base_delay * (2**attempt) + random.uniform(0, 0.5), _MAX_BACKOFF)
84+
85+
86+
def _is_retryable_transport_error(exc: Exception) -> bool:
87+
"""Return True for transient network failures that should be retried."""
88+
if httpx is not None and isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)):
89+
return True
90+
91+
try:
92+
import aiohttp
93+
except ImportError:
94+
aiohttp = None
95+
96+
if aiohttp is not None and isinstance(
97+
exc,
98+
(
99+
aiohttp.ClientConnectionError,
100+
aiohttp.ClientConnectorError,
101+
aiohttp.ClientOSError,
102+
aiohttp.ServerDisconnectedError,
103+
),
104+
):
105+
return True
106+
107+
if isinstance(exc, (ConnectionResetError, TimeoutError, urllib.error.URLError)):
108+
return True
109+
return False
110+
111+
112+
def _is_retryable_status(status: int) -> bool:
113+
return status in {502, 503, 504}
114+
115+
116+
def _retry_with_backoff(fn, max_retries: int, base_delay: float):
117+
"""Execute *fn* and retry transient failures with exponential back-off."""
118+
for attempt in range(max_retries + 1):
119+
try:
120+
return fn()
121+
except Exception as exc:
122+
if attempt >= max_retries or not _is_retryable_error(exc):
123+
raise
124+
delay = _retry_delay(attempt, base_delay)
125+
logger.debug(
126+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
127+
attempt + 1,
128+
max_retries + 1,
129+
delay,
130+
)
131+
time.sleep(delay)
132+
133+
134+
def _is_retryable_error(exc: Exception) -> bool:
135+
if _is_retryable_transport_error(exc):
136+
return True
137+
138+
if httpx is not None and isinstance(exc, httpx.HTTPStatusError):
139+
return _is_retryable_status(exc.response.status_code)
140+
141+
if isinstance(exc, HTTPError):
142+
return _is_retryable_status(exc.status_code or 0)
143+
144+
return False
145+
146+
65147
def _raise_for_status(
66148
status: int,
67149
headers: Any,
@@ -81,6 +163,14 @@ def _raise_for_status(
81163
------
82164
RateLimitError
83165
If HTTP 429 and retries are exhausted (or auto-retry is off).
166+
InvalidRequestError
167+
For HTTP 400 responses.
168+
AuthenticationError
169+
For HTTP 401/403 responses.
170+
NotFoundError
171+
For HTTP 404 responses.
172+
NetworkError
173+
For transient 502/503/504 responses after retries are exhausted.
84174
HTTPError
85175
For any other non-2xx status.
86176
"""
@@ -100,6 +190,32 @@ def _raise_for_status(
100190
msg = f"Rate limit exceeded. {detail}".strip()
101191
raise RateLimitError(msg, retry_after=retry_after)
102192

193+
if status == 400:
194+
raise InvalidRequestError("Invalid request", status_code=status)
195+
196+
if status in {401, 403}:
197+
raise AuthenticationError("Authentication failed", status_code=status)
198+
199+
if status == 404:
200+
response_body = body.decode("utf-8", errors="replace")
201+
raise NotFoundError(
202+
"Resource not found",
203+
status_code=status,
204+
response_body=response_body,
205+
)
206+
207+
if status in {502, 503, 504}:
208+
if attempt < max_retries:
209+
wait = _retry_delay(attempt, _BASE_BACKOFF)
210+
logger.debug(
211+
"Retrying request after server error (attempt %s/%s) in %.3fs",
212+
attempt + 1,
213+
max_retries + 1,
214+
wait,
215+
)
216+
return wait
217+
raise NetworkError(f"Request failed with transient server error: {status}", status_code=status)
218+
103219
try:
104220
detail = json.loads(body).get("error", {}).get("message", "")
105221
except Exception:
@@ -176,12 +292,29 @@ def request(
176292
attempt = 0
177293
while True:
178294
req = self._build_request(method, path, payload)
179-
status, headers, body = self._execute(req)
295+
try:
296+
status, headers, body = self._execute(req)
297+
except Exception as exc:
298+
if _is_retryable_transport_error(exc):
299+
if attempt >= self.max_retries:
300+
raise NetworkError(
301+
"Request failed after exhausting retries",
302+
status_code=None,
303+
) from exc
304+
delay = _retry_delay(attempt, _BASE_BACKOFF)
305+
logger.debug(
306+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
307+
attempt + 1,
308+
self.max_retries + 1,
309+
delay,
310+
)
311+
time.sleep(delay)
312+
attempt += 1
313+
continue
314+
raise
180315
wait = _raise_for_status(status, headers, body, attempt, self.max_retries)
181316
if wait is None:
182-
# success
183317
return json.loads(body) if body else {}
184-
# 429 — sleep and retry
185318
time.sleep(wait)
186319
attempt += 1
187320

@@ -269,18 +402,36 @@ async def request(
269402
) as session:
270403
while True:
271404
url = f"{url_base}/{path.lstrip('/')}"
272-
resp = await session.request(
273-
method.upper(),
274-
url,
275-
json=payload,
276-
headers=headers,
277-
)
278-
body = await resp.read()
405+
try:
406+
resp = await session.request(
407+
method.upper(),
408+
url,
409+
json=payload,
410+
headers=headers,
411+
)
412+
body = await resp.read()
413+
except Exception as exc:
414+
if _is_retryable_transport_error(exc):
415+
if attempt >= self.max_retries:
416+
raise NetworkError(
417+
"Request failed after exhausting retries",
418+
status_code=None,
419+
) from exc
420+
delay = _retry_delay(attempt, _BASE_BACKOFF)
421+
logger.debug(
422+
"Retrying request after transient failure (attempt %s/%s) in %.3fs",
423+
attempt + 1,
424+
self.max_retries + 1,
425+
delay,
426+
)
427+
await asyncio.sleep(delay)
428+
attempt += 1
429+
continue
430+
raise
279431
wait = _raise_for_status(
280432
resp.status, resp.headers, body, attempt, self.max_retries
281433
)
282434
if wait is None:
283435
return json.loads(body) if body else {}
284-
# 429 — non-blocking sleep
285436
await asyncio.sleep(wait)
286437
attempt += 1

0 commit comments

Comments
 (0)