async-rest-adapter 0.1.0__tar.gz
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-0.1.0/PKG-INFO +119 -0
- async_rest_adapter-0.1.0/README.md +91 -0
- async_rest_adapter-0.1.0/pyproject.toml +62 -0
- async_rest_adapter-0.1.0/setup.cfg +4 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter/__init__.py +37 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter/_base.py +186 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter/_models.py +37 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter.egg-info/PKG-INFO +119 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter.egg-info/SOURCES.txt +11 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter.egg-info/dependency_links.txt +1 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter.egg-info/requires.txt +7 -0
- async_rest_adapter-0.1.0/src/async_rest_adapter.egg-info/top_level.txt +1 -0
- async_rest_adapter-0.1.0/tests/test_base_adapter.py +206 -0
|
@@ -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,91 @@
|
|
|
1
|
+
# async-rest-adapter
|
|
2
|
+
|
|
3
|
+
A lightweight async REST API adapter base class built on [httpx](https://www.python-httpx.org/).
|
|
4
|
+
|
|
5
|
+
Provides a reusable `BaseAdapter` ABC that handles:
|
|
6
|
+
- Async HTTP client lifecycle (httpx.AsyncClient via context manager)
|
|
7
|
+
- Exponential-backoff retry on `httpx.RequestError`
|
|
8
|
+
- Rate-limit handling (HTTP 429 → sleep → retry)
|
|
9
|
+
- Typed error taxonomy (12 status-code → `APIError` mappings)
|
|
10
|
+
- Attribution injection into every successful response
|
|
11
|
+
- Structured `APIResponse` / `APIError` models (Pydantic v2)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install async-rest-adapter
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from async_rest_adapter import BaseAdapter, APIResponse, Attribution
|
|
23
|
+
|
|
24
|
+
class MyAdapter(BaseAdapter):
|
|
25
|
+
ATTRIBUTION = Attribution(
|
|
26
|
+
license="CC-BY-4.0",
|
|
27
|
+
text="My Data Source",
|
|
28
|
+
url="https://example.com",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
async def health_check(self) -> APIResponse:
|
|
32
|
+
try:
|
|
33
|
+
data = await self._make_request("GET", "/health")
|
|
34
|
+
return self._wrap_response(True, data)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return self._wrap_response(False, error=str(e), error_type="health_check_failed")
|
|
37
|
+
|
|
38
|
+
async with MyAdapter("my-api", "https://api.example.com") as adapter:
|
|
39
|
+
result = await adapter.health_check()
|
|
40
|
+
print(result.success, result.data)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API
|
|
44
|
+
|
|
45
|
+
### `BaseAdapter(provider_name, base_url, timeout=30.0, max_retries=3)`
|
|
46
|
+
|
|
47
|
+
Subclass and implement `health_check() -> APIResponse`.
|
|
48
|
+
|
|
49
|
+
**Key methods:**
|
|
50
|
+
- `_make_request(method, endpoint, **kwargs)` — HTTP request with retry, returns parsed body
|
|
51
|
+
- `_wrap_response(success, data, error, error_type, metadata)` — builds `APIResponse`, auto-injects `ATTRIBUTION`
|
|
52
|
+
- `_handle_rate_limit(response)` — sleep on 429, return True to retry
|
|
53
|
+
- `_handle_errors(response)` — map 4xx/5xx to `APIError`
|
|
54
|
+
|
|
55
|
+
**Test injection:**
|
|
56
|
+
```python
|
|
57
|
+
adapter.session = mock_client # inject mock httpx client in tests
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `APIResponse`
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
class APIResponse(BaseModel):
|
|
64
|
+
success: bool
|
|
65
|
+
data: Any | None
|
|
66
|
+
error: str | None
|
|
67
|
+
error_type: str | None
|
|
68
|
+
metadata: dict # includes "attribution" on every successful response
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `APIError(Exception)`
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
raise APIError("Not found", status_code=404, error_type="not_found_error")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `Attribution`
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
Attribution(license="CC-BY-4.0", text="Source Name", url="https://example.com")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Requirements
|
|
84
|
+
|
|
85
|
+
- Python 3.11+
|
|
86
|
+
- httpx ≥ 0.27
|
|
87
|
+
- pydantic ≥ 2.0
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "async-rest-adapter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async REST API adapter base class with retry, rate-limit handling, and attribution injection"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
authors = [{ name = "Tomas Amlov", email = "tomasamlov@gmail.com" }]
|
|
13
|
+
keywords = ["async", "rest", "api", "adapter", "httpx", "retry"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Framework :: AsyncIO",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.27,<1.0",
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/tomasamlov/async-rest-adapter"
|
|
33
|
+
Repository = "https://github.com/tomasamlov/async-rest-adapter"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8.0",
|
|
38
|
+
"pytest-asyncio>=0.23",
|
|
39
|
+
"pytest-httpx>=0.30",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py311"
|
|
51
|
+
line-length = 120
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "I", "UP"]
|
|
55
|
+
ignore = ["E501"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.isort]
|
|
58
|
+
known-first-party = ["async_rest_adapter"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.per-file-ignores]
|
|
61
|
+
"__init__.py" = ["F401"]
|
|
62
|
+
"tests/**" = ["F401", "E402"]
|
|
@@ -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,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/async_rest_adapter/__init__.py
|
|
4
|
+
src/async_rest_adapter/_base.py
|
|
5
|
+
src/async_rest_adapter/_models.py
|
|
6
|
+
src/async_rest_adapter.egg-info/PKG-INFO
|
|
7
|
+
src/async_rest_adapter.egg-info/SOURCES.txt
|
|
8
|
+
src/async_rest_adapter.egg-info/dependency_links.txt
|
|
9
|
+
src/async_rest_adapter.egg-info/requires.txt
|
|
10
|
+
src/async_rest_adapter.egg-info/top_level.txt
|
|
11
|
+
tests/test_base_adapter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
async_rest_adapter
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tests for async-rest-adapter core: models, _wrap_response, _handle_errors, _handle_rate_limit."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import httpx
|
|
5
|
+
from pytest_httpx import HTTPXMock
|
|
6
|
+
|
|
7
|
+
from async_rest_adapter import APIError, APIResponse, Attribution, BaseAdapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Minimal concrete adapter for testing
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
class _TestAdapter(BaseAdapter):
|
|
15
|
+
ATTRIBUTION = Attribution(license="MIT", text="Test Source", url="https://test.example.com")
|
|
16
|
+
|
|
17
|
+
async def health_check(self) -> APIResponse:
|
|
18
|
+
try:
|
|
19
|
+
data = await self._make_request("GET", "/health")
|
|
20
|
+
return self._wrap_response(success=True, data=data)
|
|
21
|
+
except APIError as e:
|
|
22
|
+
return self._wrap_response(success=False, error=e.message, error_type=e.error_type)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _NoAttributionAdapter(BaseAdapter):
|
|
26
|
+
async def health_check(self) -> APIResponse:
|
|
27
|
+
return self._wrap_response(success=True, data={})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Model tests
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def test_attribution_is_frozen():
|
|
35
|
+
attr = Attribution(license="CC-BY-4.0", text="Source", url="https://example.com")
|
|
36
|
+
with pytest.raises(Exception):
|
|
37
|
+
attr.license = "other" # type: ignore[misc]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_api_error_stores_fields():
|
|
41
|
+
err = APIError("bad request", status_code=400, error_type="client_error", details={"x": 1})
|
|
42
|
+
assert err.message == "bad request"
|
|
43
|
+
assert err.status_code == 400
|
|
44
|
+
assert err.error_type == "client_error"
|
|
45
|
+
assert err.details == {"x": 1}
|
|
46
|
+
assert str(err) == "bad request"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_api_response_defaults():
|
|
50
|
+
r = APIResponse(success=True)
|
|
51
|
+
assert r.data is None
|
|
52
|
+
assert r.error is None
|
|
53
|
+
assert r.metadata == {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# _wrap_response
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def test_wrap_response_injects_attribution():
|
|
61
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
62
|
+
result = adapter._wrap_response(success=True, data={"x": 1})
|
|
63
|
+
assert result.success is True
|
|
64
|
+
assert result.metadata["attribution"]["license"] == "MIT"
|
|
65
|
+
assert result.metadata["attribution"]["url"] == "https://test.example.com"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_wrap_response_no_attribution_on_failure():
|
|
69
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
70
|
+
result = adapter._wrap_response(success=False, error="oops", error_type="server_error")
|
|
71
|
+
assert result.success is False
|
|
72
|
+
assert "attribution" not in result.metadata
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_wrap_response_no_attribution_when_not_declared():
|
|
76
|
+
adapter = _NoAttributionAdapter("test", "https://api.example.com")
|
|
77
|
+
result = adapter._wrap_response(success=True, data={})
|
|
78
|
+
assert "attribution" not in result.metadata
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_wrap_response_preserves_existing_metadata():
|
|
82
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
83
|
+
result = adapter._wrap_response(success=True, data={}, metadata={"page": 1})
|
|
84
|
+
assert result.metadata["page"] == 1
|
|
85
|
+
assert "attribution" in result.metadata
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# _handle_errors
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize("status,expected_type", [
|
|
93
|
+
(401, "authentication_error"),
|
|
94
|
+
(403, "authorization_error"),
|
|
95
|
+
(404, "not_found_error"),
|
|
96
|
+
(429, "rate_limit_error"),
|
|
97
|
+
(422, "client_error"),
|
|
98
|
+
(500, "server_error"),
|
|
99
|
+
(503, "server_error"),
|
|
100
|
+
])
|
|
101
|
+
def test_handle_errors_maps_status_codes(status, expected_type):
|
|
102
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
103
|
+
response = httpx.Response(status_code=status, json={"error": "something went wrong"})
|
|
104
|
+
error = adapter._handle_errors(response)
|
|
105
|
+
assert error is not None
|
|
106
|
+
assert error.status_code == status
|
|
107
|
+
assert error.error_type == expected_type
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_handle_errors_returns_none_on_success():
|
|
111
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
112
|
+
response = httpx.Response(status_code=200, json={"data": []})
|
|
113
|
+
assert adapter._handle_errors(response) is None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_handle_errors_extracts_message_from_json():
|
|
117
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
118
|
+
response = httpx.Response(status_code=400, json={"message": "Invalid parameter"})
|
|
119
|
+
error = adapter._handle_errors(response)
|
|
120
|
+
assert error is not None
|
|
121
|
+
assert error.message == "Invalid parameter"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_handle_errors_fallback_message_on_non_json():
|
|
125
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
126
|
+
response = httpx.Response(status_code=500, text="Internal Server Error")
|
|
127
|
+
error = adapter._handle_errors(response)
|
|
128
|
+
assert error is not None
|
|
129
|
+
assert "500" in error.message
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# _handle_rate_limit
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
@pytest.mark.asyncio
|
|
137
|
+
async def test_handle_rate_limit_returns_true_on_429(monkeypatch):
|
|
138
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
139
|
+
slept = []
|
|
140
|
+
monkeypatch.setattr("asyncio.sleep", lambda t: slept.append(t) or _noop())
|
|
141
|
+
response = httpx.Response(status_code=429, headers={"Retry-After": "5"})
|
|
142
|
+
result = await adapter._handle_rate_limit(response)
|
|
143
|
+
assert result is True
|
|
144
|
+
assert slept == [5]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_handle_rate_limit_returns_false_on_200():
|
|
149
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
150
|
+
response = httpx.Response(status_code=200, json={})
|
|
151
|
+
result = await adapter._handle_rate_limit(response)
|
|
152
|
+
assert result is False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_handle_rate_limit_caps_wait_at_300(monkeypatch):
|
|
157
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
158
|
+
slept = []
|
|
159
|
+
monkeypatch.setattr("asyncio.sleep", lambda t: slept.append(t) or _noop())
|
|
160
|
+
response = httpx.Response(status_code=429, headers={"Retry-After": "9999"})
|
|
161
|
+
await adapter._handle_rate_limit(response)
|
|
162
|
+
assert slept[0] == 300
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# HTTP integration via pytest-httpx
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_health_check_success(httpx_mock: HTTPXMock):
|
|
171
|
+
httpx_mock.add_response(url="https://api.example.com/health", json={"status": "ok"})
|
|
172
|
+
async with _TestAdapter("test", "https://api.example.com") as adapter:
|
|
173
|
+
result = await adapter.health_check()
|
|
174
|
+
assert result.success is True
|
|
175
|
+
assert result.data == {"status": "ok"}
|
|
176
|
+
assert result.metadata["attribution"]["license"] == "MIT"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_health_check_404(httpx_mock: HTTPXMock):
|
|
181
|
+
httpx_mock.add_response(url="https://api.example.com/health", status_code=404, json={"error": "not found"})
|
|
182
|
+
async with _TestAdapter("test", "https://api.example.com") as adapter:
|
|
183
|
+
result = await adapter.health_check()
|
|
184
|
+
assert result.success is False
|
|
185
|
+
assert result.error_type == "not_found_error"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.asyncio
|
|
189
|
+
async def test_make_request_requires_context_manager():
|
|
190
|
+
adapter = _TestAdapter("test", "https://api.example.com")
|
|
191
|
+
with pytest.raises(APIError, match="context manager"):
|
|
192
|
+
await adapter._make_request("GET", "/health")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_base_adapter_is_abstract():
|
|
197
|
+
with pytest.raises(TypeError):
|
|
198
|
+
BaseAdapter("test", "https://api.example.com") # type: ignore[abstract]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Helpers
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async def _noop():
|
|
206
|
+
pass
|