Skip to content

Commit f6a4cff

Browse files
authored
Merge pull request #24 from bukkybyte/feature/11-rate-limit-handling
feat(http): implement rate-limit handling with auto-retry (#11)
2 parents 2a4df33 + 6e07cc0 commit f6a4cff

8 files changed

Lines changed: 1610 additions & 17 deletions

File tree

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: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1+
from .gateway import Gateway
2+
from .http import AsyncHTTPClient, SyncHTTPClient
13
from .errors import (
24
AuthenticationError,
35
InvalidRequestError,
46
NetworkError,
57
NotFoundError,
8+
HTTPError,
9+
RateLimitError,
610
ShadeError,
711
)
8-
from .gateway import Gateway
912

1013
__version__ = "0.1.0"
1114

1215
__all__ = [
16+
"AsyncHTTPClient",
1317
"AuthenticationError",
1418
"Gateway",
19+
"HTTPError",
1520
"InvalidRequestError",
1621
"NetworkError",
1722
"NotFoundError",
23+
"RateLimitError",
1824
"ShadeError",
19-
]
25+
"SyncHTTPClient",
26+
]

src/shade/errors.py

Lines changed: 46 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
@@ -24,6 +27,48 @@ def __str__(self) -> str:
2427
return f"{self.message} (status code: {self.status_code})"
2528

2629

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+
2772
class AuthenticationError(ShadeError):
2873
"""Raised when authentication fails or credentials are invalid."""
2974

@@ -74,4 +119,4 @@ def _parse_body(response_body: Optional[str]) -> dict:
74119

75120

76121
class NetworkError(ShadeError):
77-
"""Raised when the SDK cannot complete a network request."""
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)