Skip to content

Commit ed59f0b

Browse files
authored
Merge branch 'main' into fix/17-implement-invalid-request-error
2 parents 5b433d1 + 9271dd3 commit ed59f0b

12 files changed

Lines changed: 2420 additions & 16 deletions

poetry.lock

Lines changed: 749 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pytest = "^7.4.0"
1515
flake8 = "^6.1.0"
1616
black = "^23.7.0"
1717
isort = "^5.12.0"
18+
aiohttp = "^3.14.1"
1819

1920

2021
[build-system]

src/shade/__init__.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,76 @@
1+
import sys
2+
from types import ModuleType
3+
from typing import Optional
4+
5+
from .config import Environment
6+
from .gateway import Gateway
7+
from .http import AsyncHTTPClient, SyncHTTPClient
18
from .errors import (
29
AuthenticationError,
310
InvalidRequestError,
411
NetworkError,
512
NotFoundError,
13+
HTTPError,
14+
RateLimitError,
615
ShadeError,
716
)
8-
from .gateway import Gateway
917

1018
__version__ = "0.1.0"
1119

20+
# ShadeClient is an alias for Gateway.
21+
ShadeClient = Gateway
22+
1223
__all__ = [
24+
"AsyncHTTPClient",
1325
"AuthenticationError",
26+
"Environment",
1427
"Gateway",
28+
"HTTPError",
1529
"InvalidRequestError",
1630
"NetworkError",
1731
"NotFoundError",
32+
"RateLimitError",
33+
"ShadeClient",
1834
"ShadeError",
35+
"SyncHTTPClient",
36+
"api_base",
37+
"max_retries",
38+
"timeout",
1939
]
40+
41+
42+
class _ShadeModule(ModuleType):
43+
"""Module subclass that exposes config-backed attributes on the shade package."""
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+
@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+
75+
76+
sys.modules[__name__].__class__ = _ShadeModule

src/shade/config.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
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+
31+
32+
class Environment(str, Enum):
33+
MAINNET = "mainnet"
34+
TESTNET = "testnet"
35+
36+
@property
37+
def base_url(self) -> str:
38+
_urls: dict[str, str] = {
39+
"mainnet": "https://api.shadeprotocol.io/v1",
40+
"testnet": "https://testnet.api.shadeprotocol.io/v1",
41+
}
42+
return _urls[self.value]
43+
44+
@property
45+
def network_passphrase(self) -> str:
46+
_passphrases: dict[str, str] = {
47+
"mainnet": Network.PUBLIC_NETWORK_PASSPHRASE,
48+
"testnet": Network.TESTNET_NETWORK_PASSPHRASE,
49+
}
50+
return _passphrases[self.value]

src/shade/errors.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
"""
2+
Shade SDK exceptions.
3+
"""
14
from __future__ import annotations
25

36
import json
@@ -26,6 +29,48 @@ def __str__(self) -> str:
2629
return f"{self.message} (status code: {self.status_code})"
2730

2831

32+
class HTTPError(ShadeError):
33+
"""Raised for non-2xx responses that are not handled by a more specific error."""
34+
35+
def __init__(
36+
self,
37+
message: str,
38+
status_code: int,
39+
response_body: Optional[str] = None,
40+
) -> None:
41+
super().__init__(message, status_code=status_code, response_body=response_body)
42+
43+
44+
class RateLimitError(HTTPError):
45+
"""
46+
Raised when the API returns HTTP 429 Too Many Requests and either:
47+
- auto-retry is disabled, or
48+
- ``max_retries`` has been exhausted.
49+
50+
Attributes
51+
----------
52+
retry_after : int | None
53+
Seconds to wait before the next attempt, parsed from the
54+
``Retry-After`` response header. ``None`` if the header was absent.
55+
"""
56+
57+
def __init__(
58+
self,
59+
message: str,
60+
retry_after: Optional[int] = None,
61+
status_code: int = 429,
62+
response_body: Optional[str] = None,
63+
) -> None:
64+
super().__init__(message, status_code=status_code, response_body=response_body)
65+
self.retry_after = retry_after
66+
67+
def __str__(self) -> str: # pragma: no cover
68+
base = super().__str__()
69+
if self.retry_after is not None:
70+
return f"{base} (retry after {self.retry_after}s)"
71+
return base
72+
73+
2974
class AuthenticationError(ShadeError):
3075
"""Raised when authentication fails or credentials are invalid."""
3176

@@ -75,7 +120,44 @@ def from_response(
75120

76121

77122
class NotFoundError(ShadeError):
78-
"""Raised when an API resource cannot be found."""
123+
"""Raised on HTTP 404 responses.
124+
125+
Attributes:
126+
resource_type: Kind of resource that was not found (e.g. "payment", "invoice").
127+
resource_id: ID of the missing resource.
128+
"""
129+
130+
def __init__(
131+
self,
132+
message: str,
133+
status_code: Optional[int] = None,
134+
response_body: Optional[str] = None,
135+
resource_type: Optional[str] = None,
136+
resource_id: Optional[str] = None,
137+
) -> None:
138+
super().__init__(message, status_code, response_body)
139+
parsed = _parse_body(response_body)
140+
self.resource_type: Optional[str] = resource_type or parsed.get("resource_type")
141+
self.resource_id: Optional[str] = resource_id or parsed.get("resource_id")
142+
143+
@classmethod
144+
def from_response(
145+
cls,
146+
message: str,
147+
response_body: Optional[str] = None,
148+
) -> "NotFoundError":
149+
"""Construct from a raw 404 response body."""
150+
return cls(message, status_code=404, response_body=response_body)
151+
152+
153+
def _parse_body(response_body: Optional[str]) -> dict:
154+
if not response_body:
155+
return {}
156+
try:
157+
data = json.loads(response_body)
158+
return data if isinstance(data, dict) else {}
159+
except (json.JSONDecodeError, ValueError):
160+
return {}
79161

80162

81163
class NetworkError(ShadeError):

src/shade/gateway.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,113 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, Optional
4+
5+
from . import config as _config
6+
from .config import Environment, validate_client_settings
7+
from .http import AsyncHTTPClient, SyncHTTPClient
8+
9+
110
class Gateway:
211
"""
312
Main entry point for the Shade Payment Gateway.
13+
14+
Parameters
15+
----------
16+
api_key : str
17+
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.
26+
base_url : str
27+
Deprecated. Prefer ``api_base``.
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).
435
"""
5-
def __init__(self):
6-
pass
736

8-
def process_payment(self, amount: float, currency: str):
37+
def __init__(
38+
self,
39+
api_key: str = "",
40+
environment: Environment = Environment.MAINNET,
41+
api_base: Optional[str] = None,
42+
base_url: str = "",
43+
max_retries: Optional[int] = None,
44+
timeout: Optional[float] = None,
45+
) -> None:
46+
if not api_key:
47+
raise ValueError("api_key must be a non-empty string")
48+
self.api_key = api_key
49+
self.environment = environment
50+
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+
57+
# Resolution order: explicit api_base > module-level shade.api_base
58+
# > legacy base_url > environment URL
59+
resolved = api_base or _config.api_base or base_url or environment.base_url
60+
self._base_url = resolved.rstrip("/")
61+
62+
self._http = SyncHTTPClient(
63+
base_url=self._base_url,
64+
api_key=api_key,
65+
max_retries=resolved_max_retries,
66+
timeout=resolved_timeout,
67+
)
68+
self._async_http = AsyncHTTPClient(
69+
base_url=self._base_url,
70+
api_key=api_key,
71+
max_retries=resolved_max_retries,
72+
timeout=resolved_timeout,
73+
)
74+
75+
# ------------------------------------------------------------------
76+
# Sync API
77+
# ------------------------------------------------------------------
78+
79+
def process_payment(self, amount: float, currency: str) -> Dict[str, Any]:
980
"""
10-
Process a payment (placeholder).
81+
Process a payment (sync).
82+
83+
Parameters
84+
----------
85+
amount : float
86+
Payment amount.
87+
currency : str
88+
ISO 4217 currency code (e.g. ``"USD"``).
89+
90+
Returns
91+
-------
92+
dict
93+
API response body.
1194
"""
12-
print(f"Processing payment of {amount} {currency}...")
13-
return True
95+
return self._http.request(
96+
"POST",
97+
"/payments",
98+
{"amount": amount, "currency": currency},
99+
)
100+
101+
# ------------------------------------------------------------------
102+
# Async API
103+
# ------------------------------------------------------------------
104+
105+
async def process_payment_async(
106+
self, amount: float, currency: str
107+
) -> Dict[str, Any]:
108+
"""Async variant of :meth:`process_payment`."""
109+
return await self._async_http.request(
110+
"POST",
111+
"/payments",
112+
{"amount": amount, "currency": currency},
113+
)

0 commit comments

Comments
 (0)