Skip to content

Commit 14164f4

Browse files
Merge upstream/main into fix/13-add-request-response-debug-logging
Resolve conflicts in __init__.py (combine exports), test_gateway.py (keep patch import, drop unused pytest), and regenerate poetry.lock.
2 parents 8ff93f6 + f6a4cff commit 14164f4

9 files changed

Lines changed: 1812 additions & 17 deletions

File tree

poetry.lock

Lines changed: 751 additions & 6 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
@@ -16,6 +16,7 @@ pytest = "^7.4.0"
1616
flake8 = "^6.1.0"
1717
black = "^23.7.0"
1818
isort = "^5.12.0"
19+
aiohttp = "^3.14.1"
1920

2021

2122
[build-system]

src/shade/__init__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
from .client import ShadeClient
22
from .config import config
33
from .gateway import Gateway
4+
from .http import AsyncHTTPClient, SyncHTTPClient
5+
from .errors import (
6+
AuthenticationError,
7+
InvalidRequestError,
8+
NetworkError,
9+
NotFoundError,
10+
HTTPError,
11+
RateLimitError,
12+
ShadeError,
13+
)
414

515
__version__ = "0.1.0"
616

7-
__all__ = ["Gateway", "ShadeClient", "config"]
17+
__all__ = [
18+
"AsyncHTTPClient",
19+
"AuthenticationError",
20+
"Gateway",
21+
"HTTPError",
22+
"InvalidRequestError",
23+
"NetworkError",
24+
"NotFoundError",
25+
"RateLimitError",
26+
"ShadeClient",
27+
"ShadeError",
28+
"SyncHTTPClient",
29+
"config",
30+
]

src/shade/errors.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
Shade SDK exceptions.
3+
"""
4+
from __future__ import annotations
5+
6+
import json
7+
from typing import Optional
8+
9+
10+
class ShadeError(Exception):
11+
"""Base exception for all Shade SDK errors."""
12+
13+
def __init__(
14+
self,
15+
message: str,
16+
status_code: Optional[int] = None,
17+
response_body: Optional[str] = None,
18+
) -> None:
19+
self.message = message
20+
self.status_code = status_code
21+
self.response_body = response_body
22+
super().__init__(message)
23+
24+
def __str__(self) -> str:
25+
if self.status_code is None:
26+
return self.message
27+
return f"{self.message} (status code: {self.status_code})"
28+
29+
30+
class HTTPError(ShadeError):
31+
"""Raised for non-2xx responses that are not handled by a more specific error."""
32+
33+
def __init__(
34+
self,
35+
message: str,
36+
status_code: int,
37+
response_body: Optional[str] = None,
38+
) -> None:
39+
super().__init__(message, status_code=status_code, response_body=response_body)
40+
41+
42+
class RateLimitError(HTTPError):
43+
"""
44+
Raised when the API returns HTTP 429 Too Many Requests and either:
45+
- auto-retry is disabled, or
46+
- ``max_retries`` has been exhausted.
47+
48+
Attributes
49+
----------
50+
retry_after : int | None
51+
Seconds to wait before the next attempt, parsed from the
52+
``Retry-After`` response header. ``None`` if the header was absent.
53+
"""
54+
55+
def __init__(
56+
self,
57+
message: str,
58+
retry_after: Optional[int] = None,
59+
status_code: int = 429,
60+
response_body: Optional[str] = None,
61+
) -> None:
62+
super().__init__(message, status_code=status_code, response_body=response_body)
63+
self.retry_after = retry_after
64+
65+
def __str__(self) -> str: # pragma: no cover
66+
base = super().__str__()
67+
if self.retry_after is not None:
68+
return f"{base} (retry after {self.retry_after}s)"
69+
return base
70+
71+
72+
class AuthenticationError(ShadeError):
73+
"""Raised when authentication fails or credentials are invalid."""
74+
75+
76+
class InvalidRequestError(ShadeError):
77+
"""Raised when a request is malformed or rejected by validation."""
78+
79+
80+
class NotFoundError(ShadeError):
81+
"""Raised on HTTP 404 responses.
82+
83+
Attributes:
84+
resource_type: Kind of resource that was not found (e.g. "payment", "invoice").
85+
resource_id: ID of the missing resource.
86+
"""
87+
88+
def __init__(
89+
self,
90+
message: str,
91+
status_code: Optional[int] = None,
92+
response_body: Optional[str] = None,
93+
resource_type: Optional[str] = None,
94+
resource_id: Optional[str] = None,
95+
) -> None:
96+
super().__init__(message, status_code, response_body)
97+
parsed = _parse_body(response_body)
98+
self.resource_type: Optional[str] = resource_type or parsed.get("resource_type")
99+
self.resource_id: Optional[str] = resource_id or parsed.get("resource_id")
100+
101+
@classmethod
102+
def from_response(
103+
cls,
104+
message: str,
105+
response_body: Optional[str] = None,
106+
) -> "NotFoundError":
107+
"""Construct from a raw 404 response body."""
108+
return cls(message, status_code=404, response_body=response_body)
109+
110+
111+
def _parse_body(response_body: Optional[str]) -> dict:
112+
if not response_body:
113+
return {}
114+
try:
115+
data = json.loads(response_body)
116+
return data if isinstance(data, dict) else {}
117+
except (json.JSONDecodeError, ValueError):
118+
return {}
119+
120+
121+
class NetworkError(ShadeError):
122+
"""Raised when the SDK cannot complete a network request."""

src/shade/gateway.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,89 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, Optional
4+
5+
from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES
6+
7+
18
class Gateway:
29
"""
310
Main entry point for the Shade Payment Gateway.
11+
12+
Parameters
13+
----------
14+
api_key : str
15+
Your Shade API key.
16+
base_url : str
17+
Override the default API base URL (useful for testing).
18+
max_retries : int
19+
Number of automatic retries on HTTP 429. Defaults to
20+
``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable.
21+
timeout : float
22+
Per-request socket timeout in seconds.
423
"""
5-
def __init__(self):
6-
pass
724

8-
def process_payment(self, amount: float, currency: str):
25+
_DEFAULT_BASE_URL = "https://api.shadeprotocol.io/v1"
26+
27+
def __init__(
28+
self,
29+
api_key: str = "",
30+
base_url: str = "",
31+
max_retries: int = DEFAULT_MAX_RETRIES,
32+
timeout: float = 30.0,
33+
) -> None:
34+
if not api_key:
35+
raise ValueError("api_key must be a non-empty string")
36+
self.api_key = api_key
37+
self._base_url = base_url or self._DEFAULT_BASE_URL
38+
self._http = SyncHTTPClient(
39+
base_url=self._base_url,
40+
api_key=api_key,
41+
max_retries=max_retries,
42+
timeout=timeout,
43+
)
44+
self._async_http = AsyncHTTPClient(
45+
base_url=self._base_url,
46+
api_key=api_key,
47+
max_retries=max_retries,
48+
timeout=timeout,
49+
)
50+
51+
# ------------------------------------------------------------------
52+
# Sync API
53+
# ------------------------------------------------------------------
54+
55+
def process_payment(self, amount: float, currency: str) -> Dict[str, Any]:
956
"""
10-
Process a payment (placeholder).
57+
Process a payment (sync).
58+
59+
Parameters
60+
----------
61+
amount : float
62+
Payment amount.
63+
currency : str
64+
ISO 4217 currency code (e.g. ``"USD"``).
65+
66+
Returns
67+
-------
68+
dict
69+
API response body.
1170
"""
12-
print(f"Processing payment of {amount} {currency}...")
13-
return True
71+
return self._http.request(
72+
"POST",
73+
"/payments",
74+
{"amount": amount, "currency": currency},
75+
)
76+
77+
# ------------------------------------------------------------------
78+
# Async API
79+
# ------------------------------------------------------------------
80+
81+
async def process_payment_async(
82+
self, amount: float, currency: str
83+
) -> Dict[str, Any]:
84+
"""Async variant of :meth:`process_payment`."""
85+
return await self._async_http.request(
86+
"POST",
87+
"/payments",
88+
{"amount": amount, "currency": currency},
89+
)

0 commit comments

Comments
 (0)