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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ async_rest_adapter