Skip to content

Commit 880e9ed

Browse files
committed
prepared 0.1.0 release
1 parent 816fe70 commit 880e9ed

9 files changed

Lines changed: 378 additions & 8 deletions

File tree

.gitlab-ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
#include:
2-
# project: mnt/ci
3-
# file: modules/python.yaml
1+
include:
2+
project: mnt/ci
3+
file: modules/python.yaml

README.md

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,338 @@
11
# extapi
2+
3+
Library for performing HTTP calls to external systems. Made to be modular, extensible and easy to use.
4+
5+
## Installation
6+
7+
To use with aiohttp backend:
8+
```bash
9+
pip install 'extapi[aiohttp]'
10+
```
11+
12+
To use with httpx backend:
13+
```bash
14+
pip install 'extapi[httpx]'
15+
```
16+
17+
## Quick example
18+
19+
Using aiohttp:
20+
21+
```python
22+
import asyncio
23+
from extapi.http.backends.aiohttp import AiohttpExecutor
24+
25+
26+
async def main():
27+
async with AiohttpExecutor() as executor:
28+
async with await executor.get('https://httpbin.org/get') as response:
29+
print(response.status)
30+
print(await response.read())
31+
32+
33+
asyncio.run(main())
34+
```
35+
36+
Using httpx:
37+
38+
```python
39+
import asyncio
40+
from extapi.http.backends.httpx import HttpxExecutor
41+
42+
43+
async def main():
44+
async with HttpxExecutor() as executor:
45+
async with await executor.get('https://httpbin.org/get') as response:
46+
print(response.status)
47+
print(await response.read())
48+
49+
50+
asyncio.run(main())
51+
```
52+
53+
## Features
54+
55+
### Retryable executor
56+
57+
You can use the `RetryableExecutor` class to retry requests in case of failure. It will retry the request until the maximum number of retries is reached or the request is successful.
58+
59+
There is also a set of additional `Addon`s to a RetyableExecutor. Usually you would probably use `RetryableExecutor` in your code base.
60+
61+
```python
62+
import asyncio
63+
from extapi.http.backends.aiohttp import AiohttpExecutor
64+
from extapi.http.executors.retry import RetryableExecutor
65+
66+
67+
async def main():
68+
async with AiohttpExecutor() as backend:
69+
executor = RetryableExecutor(backend, max_retries=3)
70+
71+
async with await executor.get('https://httpbin.org/get') as response:
72+
print(response.status)
73+
print(await response.read())
74+
75+
76+
asyncio.run(main())
77+
```
78+
79+
As you can see nothing changes in terms of usage, but now we have retries in case of failure (it may be 500 errors, 401 with custom authentication and token reacquiring for example, TimeoutError, any other Exceptions). There is a default set of addons that are being added to a `RetryableExecutor`:
80+
81+
```python
82+
default_addons = [
83+
Retry5xxAddon(),
84+
Retry429Addon(),
85+
LoggingAddon(),
86+
]
87+
```
88+
89+
First 2 retry 5xx and 429 status codes. The last one logs the request and response.
90+
91+
### Addons
92+
93+
Addons are a way to extend the functionality of an executor. They can be used to add additional functionality to the executor, like logging, retrying requests, headers passing, authentication, etc.
94+
95+
#### LoggingAddon
96+
97+
This one is simple ad just logs the fact of a request being sent and a received response.
98+
99+
100+
#### VerboseLoggingAddon
101+
102+
This one is more verbose and logs the request and response headers and body.
103+
104+
#### Retry5xxAddon
105+
106+
This one retries the request in case of 5xx status code.
107+
108+
#### Retry429Addon
109+
110+
This one retries the request in case of 429 status code. It also waits for the `Retry-After` header if it is present in the response and waits the specified time.
111+
112+
#### StatusValidationAddon
113+
114+
This one validates the status code of the response. If the status code is not in the list of allowed status codes, it raises an exception.
115+
116+
#### AddHeadersAddon
117+
118+
This one adds headers to the request. It expects a callable or an awaitable that accepts headers in order to modify them.
119+
120+
In the following example we add an `X-Api-Key` header on each request (in case of errors this would be 3 requests).
121+
122+
_Note: the function `headers_patch` is called before each request in case of retries, so you can leverage that to add some keys rotating logic in this case, for example._
123+
124+
```python
125+
import asyncio
126+
127+
from multidict import CIMultiDict
128+
129+
from extapi.http.addons.headers import AddHeadersAddon
130+
from extapi.http.backends.aiohttp import AiohttpExecutor
131+
from extapi.http.executors.retry import RetryableExecutor
132+
133+
134+
async def headers_patch(headers: CIMultiDict):
135+
headers["X-Api-Key"] = "some-api-token"
136+
137+
138+
async def main():
139+
async with AiohttpExecutor() as backend:
140+
executor = RetryableExecutor(backend, max_retries=3, addons=[
141+
AddHeadersAddon(headers_patch)
142+
])
143+
144+
async with await executor.get('https://httpbin.org/get') as response:
145+
print(await response.read())
146+
147+
148+
asyncio.run(main())
149+
```
150+
151+
#### BearerAuthAddon:
152+
153+
This one adds a `Authorization` header with a `Bearer` token. As in the previous example, you may execute some complex logic in the callable - like issuing new token in case of 401 error.
154+
155+
```python
156+
import asyncio
157+
158+
from extapi.http.addons.auth import BearerAuthAddon
159+
from extapi.http.backends.aiohttp import AiohttpExecutor
160+
from extapi.http.executors.retry import RetryableExecutor
161+
162+
163+
async def token_getter() -> str:
164+
return "some-api-token"
165+
166+
167+
async def main():
168+
async with AiohttpExecutor() as backend:
169+
executor = RetryableExecutor(backend, max_retries=3, addons=[
170+
BearerAuthAddon(token_getter)
171+
])
172+
173+
async with await executor.get('https://httpbin.org/get') as response:
174+
print(await response.read())
175+
176+
177+
asyncio.run(main())
178+
```
179+
180+
##### Custom
181+
182+
You can also extend any existing addons or create your own. Just inherit from `Addon` and implement the `execute` method.
183+
184+
This is the `Addon` protocol and your custom addon has to satisfy it.
185+
```python
186+
@runtime_checkable
187+
class Addon(Protocol[T]):
188+
async def before_request(self, request: RequestData) -> None:
189+
return None
190+
191+
async def process_response(
192+
self, request: RequestData, response: Response[T]
193+
) -> Response[T]:
194+
return response
195+
196+
async def process_error(self, request: RequestData, error: Exception) -> None:
197+
return None
198+
```
199+
200+
If you want to execute some custom logic in case of retry you would also need to satisfy a `Retryable` protocol. The `need_retry` function must return a tuple (bool, float | None) where the first element is a flag if the request should be retried and the second is a delay in seconds before the next retry if any.
201+
202+
```python
203+
@runtime_checkable
204+
class Retryable(Protocol[T_contr]):
205+
async def need_retry(
206+
self, response: Response[T_contr]
207+
) -> tuple[bool, float | None]: ...
208+
```
209+
210+
And as an example Retry5xxAddon:
211+
212+
```python
213+
class Retry5xxAddon(Retryable[T], Generic[T]):
214+
async def need_retry(self, response: Response[T]) -> tuple[bool, float | None]:
215+
if response.status >= 500:
216+
return True, 1.0
217+
218+
return False, None
219+
```
220+
221+
### Other executors
222+
223+
You can create your own executor by inheriting from the `Executor` class and implementing the `execute` method. There are a couple more extra executors that modify behaviour of the initial request:
224+
225+
* `OpenTelemetryExecutor` — adds OpenTelemetry tracing to the request.
226+
* `PrometheusMetricsExecutor` — tracks Prometheus metrics from the request/response.
227+
* `ConcurrencyLimitedExecutor` - limits amount of concurrent requests that can happen simultaneously.
228+
* `RateLimitedExecutor` — limits the amount of requests per second/minute. You can choose the window.
229+
230+
Let's see at the full-featured example:
231+
232+
```python
233+
import asyncio
234+
235+
from multidict import CIMultiDict
236+
237+
from extapi.http.addons.auth import BearerAuthAddon
238+
from extapi.http.addons.headers import AddHeadersAddon
239+
from extapi.http.addons.status import StatusValidationAddon
240+
from extapi.http.backends.aiohttp import AiohttpExecutor
241+
from extapi.http.executors.limiters import (
242+
ConcurrencyLimitedExecutor,
243+
RateLimitedExecutor,
244+
)
245+
from extapi.http.executors.metrics import PrometheusMetricsExecutor
246+
from extapi.http.executors.retry import RetryableExecutor
247+
from extapi.http.executors.trace import OpenTelemetryExecutor
248+
from extapi.http.metrics.container import MetricsContainer
249+
from extapi.limiters.concurrency.local import LocalConcurrencyLimiter
250+
from extapi.limiters.rps.local import LocalRateLimiter
251+
252+
253+
class TestHeaders:
254+
async def __call__(self, headers: CIMultiDict) -> None:
255+
headers.add("X-Test-Header", "test")
256+
257+
258+
class FooTokenGetter:
259+
def __call__(self) -> str:
260+
return "foo-bar"
261+
262+
263+
async def main():
264+
async with AiohttpExecutor() as base:
265+
executor = base.generalize()
266+
executor = OpenTelemetryExecutor(executor)
267+
executor = PrometheusMetricsExecutor(
268+
executor, metrics_container=MetricsContainer(metrics_prefix="demo")
269+
)
270+
executor = RateLimitedExecutor(
271+
executor,
272+
rate_limiter=LocalRateLimiter(rate_limit=50, rate_limit_window_seconds=1),
273+
)
274+
executor = ConcurrencyLimitedExecutor(
275+
executor,
276+
concurrency_limiter=LocalConcurrencyLimiter(max_concurrency=100),
277+
)
278+
279+
executor = RetryableExecutor(
280+
executor,
281+
addons=[
282+
BearerAuthAddon(FooTokenGetter()),
283+
AddHeadersAddon(TestHeaders()),
284+
StatusValidationAddon((200,)),
285+
],
286+
)
287+
288+
async with await executor.get('https://httpbin.org/get') as response:
289+
print(await response.read())
290+
291+
asyncio.run(main())
292+
```
293+
294+
In this example we leverage opentelemetry, metrics, rate limiting, retrying mechanics. We also add a custom header and a bearer token to the request. We also validate the status code of the response to be 200.
295+
296+
297+
### What to depend on?
298+
299+
If you need to accept somewhere an executor in your code you may reference a `AbstractExecutor` as the most abstract class that all executors inherit from.
300+
301+
```python
302+
from typing import Any, TypeVar
303+
304+
from extapi.http.abc import AbstractExecutor
305+
306+
T = TypeVar("T")
307+
308+
async def httpbin_get(executor: AbstractExecutor[T]) -> Any:
309+
async with await executor.get('https://httpbin.org/get') as response:
310+
return await response.json()
311+
312+
```
313+
314+
This way you may add any executor to your code, and it will work with it.
315+
316+
317+
### Get an underlying backend executor
318+
319+
In some cases you may need to get an underlying backend executor to be sure that you may send a request in a specific format for this particular executor. You can do so like the following:
320+
321+
```python
322+
import asyncio
323+
324+
from extapi.http.backends.aiohttp import AiohttpExecutor
325+
from extapi.http.executors.retry import RetryableExecutor
326+
from extapi.http.executors.wrapped import unwrap_executor
327+
328+
329+
async def main():
330+
async with AiohttpExecutor() as backend:
331+
executor = RetryableExecutor(backend)
332+
333+
assert isinstance(unwrap_executor(executor), AiohttpExecutor)
334+
335+
# ... the rest
336+
337+
asyncio.run(main())
338+
```
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# pragma: no cover
2+
13
import asyncio
24
import logging
35
from typing import TypeVar

extapi/http/addons/log.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async def process_error(self, request: RequestData, error: Exception) -> None:
6666
)
6767

6868

69-
class VerboseLoggingExecutor(LoggingAddon[T], Generic[T]):
69+
class VerboseLoggingAddon(LoggingAddon[T], Generic[T]):
7070
def __init__(
7171
self,
7272
*,

extapi/http/executors/metrics.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,20 @@
2121

2222
class PrometheusMetricsExecutor(WrappedExecutor[T], Generic[T]):
2323
def __init__(
24-
self, executor: AbstractExecutor[T], *, metrics_container: MetricsContainer
24+
self,
25+
executor: AbstractExecutor[T],
26+
*,
27+
metrics_container: MetricsContainer,
28+
disable_warnings: bool = False,
2529
):
2630
super().__init__(executor)
2731
self._metrics_container = metrics_container
32+
self._disable_warnings = disable_warnings
2833

2934
async def execute(self, request: RequestData) -> Response[T]:
3035
path_template = request.kwargs.pop("path_template", None)
31-
if path_template is None:
36+
37+
if not self._disable_warnings and path_template is None:
3238
warnings.warn(
3339
"It is highly recommended to pass `path_template` "
3440
"argument to the executor in order to not explode the "

0 commit comments

Comments
 (0)