async-rest-adapter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_rest_adapter/__init__.py +37 -0
- async_rest_adapter/_base.py +186 -0
- async_rest_adapter/_models.py +37 -0
- async_rest_adapter-0.1.0.dist-info/METADATA +119 -0
- async_rest_adapter-0.1.0.dist-info/RECORD +7 -0
- async_rest_adapter-0.1.0.dist-info/WHEEL +5 -0
- async_rest_adapter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""async-rest-adapter — Async REST API adapter base class.
|
|
2
|
+
|
|
3
|
+
Provides BaseAdapter, the response/error models, and Attribution for building
|
|
4
|
+
async REST API clients with built-in retry, rate-limit handling, and
|
|
5
|
+
open-data attribution injection.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from async_rest_adapter import BaseAdapter, APIResponse, APIError, Attribution
|
|
10
|
+
|
|
11
|
+
class MyAdapter(BaseAdapter):
|
|
12
|
+
ATTRIBUTION = Attribution(license="CC-BY-4.0", text="My Source", url="https://example.com")
|
|
13
|
+
|
|
14
|
+
async def health_check(self) -> APIResponse:
|
|
15
|
+
try:
|
|
16
|
+
await self._make_request("GET", "/health")
|
|
17
|
+
return self._wrap_response(success=True, data={"status": "ok"})
|
|
18
|
+
except APIError as e:
|
|
19
|
+
return self._wrap_response(success=False, error=e.message, error_type=e.error_type)
|
|
20
|
+
|
|
21
|
+
async with MyAdapter("my-api", "https://api.example.com") as adapter:
|
|
22
|
+
result = await adapter.health_check()
|
|
23
|
+
print(result.metadata["attribution"])
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from async_rest_adapter._base import BaseAdapter
|
|
27
|
+
from async_rest_adapter._models import APIError, APIResponse, Attribution
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BaseAdapter",
|
|
33
|
+
"APIResponse",
|
|
34
|
+
"APIError",
|
|
35
|
+
"Attribution",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from async_rest_adapter._models import APIError, APIResponse, Attribution
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseAdapter(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Abstract base for async REST API adapters.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
class MyAdapter(BaseAdapter):
|
|
20
|
+
ATTRIBUTION = Attribution(license="CC-BY-4.0", text="My Source", url="https://example.com")
|
|
21
|
+
|
|
22
|
+
async def health_check(self) -> APIResponse:
|
|
23
|
+
try:
|
|
24
|
+
await self._make_request("GET", "/health")
|
|
25
|
+
return self._wrap_response(success=True, data={"status": "ok"})
|
|
26
|
+
except APIError as e:
|
|
27
|
+
return self._wrap_response(success=False, error=e.message, error_type=e.error_type)
|
|
28
|
+
|
|
29
|
+
async with MyAdapter("my-api", "https://api.example.com") as adapter:
|
|
30
|
+
result = await adapter.health_check()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
ATTRIBUTION: Optional[Attribution] = None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
provider_name: str,
|
|
38
|
+
base_url: str,
|
|
39
|
+
timeout: float = 30.0,
|
|
40
|
+
max_retries: int = 3,
|
|
41
|
+
):
|
|
42
|
+
self.provider_name = provider_name
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.max_retries = max_retries
|
|
46
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
47
|
+
self.default_headers: Dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def session(self) -> Optional[httpx.AsyncClient]:
|
|
51
|
+
"""Alias for _client. Allows unit tests to inject mock clients via adapter.session = mock."""
|
|
52
|
+
return self._client
|
|
53
|
+
|
|
54
|
+
@session.setter
|
|
55
|
+
def session(self, value: Optional[httpx.AsyncClient]) -> None:
|
|
56
|
+
self._client = value
|
|
57
|
+
|
|
58
|
+
async def __aenter__(self) -> "BaseAdapter":
|
|
59
|
+
self._client = httpx.AsyncClient(
|
|
60
|
+
timeout=httpx.Timeout(self.timeout),
|
|
61
|
+
headers=self.default_headers,
|
|
62
|
+
follow_redirects=True,
|
|
63
|
+
)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
67
|
+
if self._client:
|
|
68
|
+
await self._client.aclose()
|
|
69
|
+
|
|
70
|
+
async def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
71
|
+
"""Make an HTTP request with exponential-backoff retry. Returns raw parsed body."""
|
|
72
|
+
if not self._client:
|
|
73
|
+
raise APIError("Client not initialized. Use async context manager.")
|
|
74
|
+
|
|
75
|
+
url = f"{self.base_url}{endpoint}"
|
|
76
|
+
|
|
77
|
+
for attempt in range(self.max_retries + 1):
|
|
78
|
+
try:
|
|
79
|
+
response = await self._client.request(method, url, **kwargs)
|
|
80
|
+
|
|
81
|
+
if await self._handle_rate_limit(response):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
api_error = self._handle_errors(response)
|
|
85
|
+
if api_error:
|
|
86
|
+
raise api_error
|
|
87
|
+
|
|
88
|
+
content_type = response.headers.get("content-type", "")
|
|
89
|
+
if "application/json" in content_type:
|
|
90
|
+
return response.json()
|
|
91
|
+
elif content_type.startswith("text/"):
|
|
92
|
+
return {"text": response.text, "content_type": content_type}
|
|
93
|
+
else:
|
|
94
|
+
return {
|
|
95
|
+
"content": response.content,
|
|
96
|
+
"content_type": content_type,
|
|
97
|
+
"content_length": response.headers.get("content-length"),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
except httpx.RequestError as e:
|
|
101
|
+
if attempt == self.max_retries:
|
|
102
|
+
raise APIError(
|
|
103
|
+
f"Request failed after {self.max_retries} retries: {e}",
|
|
104
|
+
error_type="network_error",
|
|
105
|
+
)
|
|
106
|
+
wait_time = 2**attempt
|
|
107
|
+
logger.warning(
|
|
108
|
+
"%s request failed (attempt %d), retrying in %ds: %s",
|
|
109
|
+
self.provider_name, attempt + 1, wait_time, e,
|
|
110
|
+
)
|
|
111
|
+
await asyncio.sleep(wait_time)
|
|
112
|
+
|
|
113
|
+
raise APIError("Maximum retries exceeded", error_type="max_retries_exceeded")
|
|
114
|
+
|
|
115
|
+
def _wrap_response(
|
|
116
|
+
self,
|
|
117
|
+
success: bool,
|
|
118
|
+
data: Any = None,
|
|
119
|
+
error: Optional[str] = None,
|
|
120
|
+
error_type: Optional[str] = None,
|
|
121
|
+
metadata: Optional[Dict] = None,
|
|
122
|
+
) -> APIResponse:
|
|
123
|
+
"""Build APIResponse, auto-injecting ATTRIBUTION into metadata on success."""
|
|
124
|
+
meta = metadata or {}
|
|
125
|
+
if success and self.ATTRIBUTION is not None:
|
|
126
|
+
meta["attribution"] = {
|
|
127
|
+
"license": self.ATTRIBUTION.license,
|
|
128
|
+
"text": self.ATTRIBUTION.text,
|
|
129
|
+
"url": self.ATTRIBUTION.url,
|
|
130
|
+
}
|
|
131
|
+
return APIResponse(success=success, data=data, error=error, error_type=error_type, metadata=meta)
|
|
132
|
+
|
|
133
|
+
async def _handle_rate_limit(self, response: httpx.Response) -> bool:
|
|
134
|
+
"""Sleep and return True on 429, signalling _make_request to retry."""
|
|
135
|
+
if response.status_code != 429:
|
|
136
|
+
return False
|
|
137
|
+
retry_after = response.headers.get("Retry-After", "60")
|
|
138
|
+
try:
|
|
139
|
+
wait_time = min(int(retry_after), 300)
|
|
140
|
+
except ValueError:
|
|
141
|
+
wait_time = 60
|
|
142
|
+
logger.warning("%s rate limit hit, waiting %ds", self.provider_name, wait_time)
|
|
143
|
+
await asyncio.sleep(wait_time)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def _handle_errors(self, response: httpx.Response) -> Optional[APIError]:
|
|
147
|
+
"""Map HTTP 4xx/5xx status codes to typed APIError. Returns None on 2xx/3xx."""
|
|
148
|
+
if response.status_code < 400:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
error_data = response.json()
|
|
153
|
+
message = error_data.get("message", error_data.get("error", f"HTTP {response.status_code}"))
|
|
154
|
+
details: Dict = error_data
|
|
155
|
+
except Exception:
|
|
156
|
+
message = f"HTTP {response.status_code}"
|
|
157
|
+
details = {}
|
|
158
|
+
|
|
159
|
+
status = response.status_code
|
|
160
|
+
if status == 401:
|
|
161
|
+
error_type = "authentication_error"
|
|
162
|
+
elif status == 403:
|
|
163
|
+
error_type = "authorization_error"
|
|
164
|
+
elif status == 404:
|
|
165
|
+
error_type = "not_found_error"
|
|
166
|
+
elif status == 429:
|
|
167
|
+
error_type = "rate_limit_error"
|
|
168
|
+
elif 400 <= status < 500:
|
|
169
|
+
error_type = "client_error"
|
|
170
|
+
else:
|
|
171
|
+
error_type = "server_error"
|
|
172
|
+
|
|
173
|
+
return APIError(message=message, status_code=status, error_type=error_type, details=details)
|
|
174
|
+
|
|
175
|
+
@abstractmethod
|
|
176
|
+
async def health_check(self) -> APIResponse:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
async def _validate_response(self, response: Dict[str, Any]) -> bool:
|
|
180
|
+
return isinstance(response, dict)
|
|
181
|
+
|
|
182
|
+
def _build_headers(self, additional_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
183
|
+
headers = self.default_headers.copy()
|
|
184
|
+
if additional_headers:
|
|
185
|
+
headers.update(additional_headers)
|
|
186
|
+
return headers
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Attribution:
|
|
9
|
+
"""Open-data attribution metadata. Declare as ATTRIBUTION at class level on each adapter."""
|
|
10
|
+
license: str # e.g. "CC-BY-4.0"
|
|
11
|
+
text: str # e.g. "Kolada / RKA"
|
|
12
|
+
url: str # canonical source URL
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(Exception):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
status_code: Optional[int] = None,
|
|
20
|
+
error_type: Optional[str] = None,
|
|
21
|
+
details: Optional[Dict] = None,
|
|
22
|
+
):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.error_type = error_type
|
|
27
|
+
self.details = details or {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIResponse(BaseModel):
|
|
31
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
32
|
+
|
|
33
|
+
success: bool
|
|
34
|
+
data: Optional[Any] = None
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
error_type: Optional[str] = None
|
|
37
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: async-rest-adapter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async REST API adapter base class with retry, rate-limit handling, and attribution injection
|
|
5
|
+
Author-email: Tomas Amlov <tomasamlov@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tomasamlov/async-rest-adapter
|
|
8
|
+
Project-URL: Repository, https://github.com/tomasamlov/async-rest-adapter
|
|
9
|
+
Keywords: async,rest,api,adapter,httpx,retry
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Framework :: AsyncIO
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-httpx>=0.30; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# async-rest-adapter
|
|
30
|
+
|
|
31
|
+
A lightweight async REST API adapter base class built on [httpx](https://www.python-httpx.org/).
|
|
32
|
+
|
|
33
|
+
Provides a reusable `BaseAdapter` ABC that handles:
|
|
34
|
+
- Async HTTP client lifecycle (httpx.AsyncClient via context manager)
|
|
35
|
+
- Exponential-backoff retry on `httpx.RequestError`
|
|
36
|
+
- Rate-limit handling (HTTP 429 → sleep → retry)
|
|
37
|
+
- Typed error taxonomy (12 status-code → `APIError` mappings)
|
|
38
|
+
- Attribution injection into every successful response
|
|
39
|
+
- Structured `APIResponse` / `APIError` models (Pydantic v2)
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install async-rest-adapter
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from async_rest_adapter import BaseAdapter, APIResponse, Attribution
|
|
51
|
+
|
|
52
|
+
class MyAdapter(BaseAdapter):
|
|
53
|
+
ATTRIBUTION = Attribution(
|
|
54
|
+
license="CC-BY-4.0",
|
|
55
|
+
text="My Data Source",
|
|
56
|
+
url="https://example.com",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def health_check(self) -> APIResponse:
|
|
60
|
+
try:
|
|
61
|
+
data = await self._make_request("GET", "/health")
|
|
62
|
+
return self._wrap_response(True, data)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return self._wrap_response(False, error=str(e), error_type="health_check_failed")
|
|
65
|
+
|
|
66
|
+
async with MyAdapter("my-api", "https://api.example.com") as adapter:
|
|
67
|
+
result = await adapter.health_check()
|
|
68
|
+
print(result.success, result.data)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
### `BaseAdapter(provider_name, base_url, timeout=30.0, max_retries=3)`
|
|
74
|
+
|
|
75
|
+
Subclass and implement `health_check() -> APIResponse`.
|
|
76
|
+
|
|
77
|
+
**Key methods:**
|
|
78
|
+
- `_make_request(method, endpoint, **kwargs)` — HTTP request with retry, returns parsed body
|
|
79
|
+
- `_wrap_response(success, data, error, error_type, metadata)` — builds `APIResponse`, auto-injects `ATTRIBUTION`
|
|
80
|
+
- `_handle_rate_limit(response)` — sleep on 429, return True to retry
|
|
81
|
+
- `_handle_errors(response)` — map 4xx/5xx to `APIError`
|
|
82
|
+
|
|
83
|
+
**Test injection:**
|
|
84
|
+
```python
|
|
85
|
+
adapter.session = mock_client # inject mock httpx client in tests
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `APIResponse`
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
class APIResponse(BaseModel):
|
|
92
|
+
success: bool
|
|
93
|
+
data: Any | None
|
|
94
|
+
error: str | None
|
|
95
|
+
error_type: str | None
|
|
96
|
+
metadata: dict # includes "attribution" on every successful response
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `APIError(Exception)`
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
raise APIError("Not found", status_code=404, error_type="not_found_error")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `Attribution`
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
Attribution(license="CC-BY-4.0", text="Source Name", url="https://example.com")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
- Python 3.11+
|
|
114
|
+
- httpx ≥ 0.27
|
|
115
|
+
- pydantic ≥ 2.0
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
async_rest_adapter/__init__.py,sha256=SpvZFNsB0jJoLwBuAOJc9wt9Rb6x5xlYAFbFcqJVNaA,1241
|
|
2
|
+
async_rest_adapter/_base.py,sha256=JUKin9SgDG_WeoE08Pl1iDxRlvxmLS6i6-NdwXscnyM,6843
|
|
3
|
+
async_rest_adapter/_models.py,sha256=hH9Vm3XdpW9n7kFX4P1z6AaTiVNek74pOEDrUg2Artk,1050
|
|
4
|
+
async_rest_adapter-0.1.0.dist-info/METADATA,sha256=_oERHseetj0Fp_78GrhImmMpLR64e4oyR-6RUgucMBU,3749
|
|
5
|
+
async_rest_adapter-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
async_rest_adapter-0.1.0.dist-info/top_level.txt,sha256=4lQKkLEhEZ3IggbqJuRIi33FjnV5XkZzakOSPxjP2b0,19
|
|
7
|
+
async_rest_adapter-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
async_rest_adapter
|