requester-kit 0.3.0__tar.gz → 0.4.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.
- {requester_kit-0.3.0 → requester_kit-0.4.0}/CHANGELOG.md +7 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/PKG-INFO +1 -1
- {requester_kit-0.3.0 → requester_kit-0.4.0}/pyproject.toml +1 -1
- {requester_kit-0.3.0 → requester_kit-0.4.0}/requester_kit/client.py +85 -28
- {requester_kit-0.3.0 → requester_kit-0.4.0}/requester_kit/types.py +5 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/tests/test_requester.py +37 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/uv.lock +1 -1
- {requester_kit-0.3.0 → requester_kit-0.4.0}/.github/workflows/ci.yml +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/.github/workflows/publish.yml +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/.github/workflows/tests.yml +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/.gitignore +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/.gitlab-ci.yml +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/LICENSE +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/Makefile +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/README.md +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/requester_kit/__init__.py +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/requester_kit/py.typed +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/tests/__init__.py +0 -0
- {requester_kit-0.3.0 → requester_kit-0.4.0}/tests/conftest.py +0 -0
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0.0/).
|
|
5
5
|
|
|
6
|
+
### Added
|
|
7
|
+
- Add `headers` and `cookies` fields to `RequesterKitResponse`.
|
|
8
|
+
- Add `error_msg` field to `RequesterKitResponse` for error details.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Add `verify` support to `BaseRequesterKit` constructor for TLS verification configuration.
|
|
12
|
+
|
|
6
13
|
|
|
7
14
|
## [1.1.2] - 2024-11-05
|
|
8
15
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: requester-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Async HTTP connector toolkit with retries, testing helpers, and Prometheus metrics.
|
|
5
5
|
Project-URL: Homepage, https://github.com/evstratbg/requester-kit
|
|
6
6
|
Project-URL: Repository, https://github.com/evstratbg/requester-kit
|
|
@@ -4,6 +4,7 @@ import inspect
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from http import HTTPStatus
|
|
7
|
+
from http.cookies import SimpleCookie
|
|
7
8
|
from importlib import import_module
|
|
8
9
|
from json import JSONDecodeError
|
|
9
10
|
from typing import TYPE_CHECKING, Optional, cast
|
|
@@ -68,7 +69,7 @@ class RequesterKitRequestError(Exception):
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
class BaseRequesterKit:
|
|
71
|
-
def __init__(
|
|
72
|
+
def __init__( # noqa: PLR0913
|
|
72
73
|
self,
|
|
73
74
|
base_url: str = "",
|
|
74
75
|
auth: Optional[types.RequestAuth] = None,
|
|
@@ -76,6 +77,7 @@ class BaseRequesterKit:
|
|
|
76
77
|
headers: Optional[types.RequestHeaders] = None,
|
|
77
78
|
cookies: Optional[types.RequestCookies] = None,
|
|
78
79
|
timeout: Optional[float] = None,
|
|
80
|
+
verify: Optional[types.RequestVerify] = None,
|
|
79
81
|
retryer_settings: Optional[RetrySettings] = None,
|
|
80
82
|
logger_settings: Optional[LoggerSettings] = None,
|
|
81
83
|
*,
|
|
@@ -85,6 +87,8 @@ class BaseRequesterKit:
|
|
|
85
87
|
self._logger_settings = logger_settings or LoggerSettings()
|
|
86
88
|
self._logger = logging.getLogger(type(self).__name__)
|
|
87
89
|
self._enable_prometheus_metrics = enable_prometheus_metrics
|
|
90
|
+
self._verify = verify
|
|
91
|
+
transport_verify: types.RequestVerify = True if verify is None else verify
|
|
88
92
|
self._client = AsyncClient(
|
|
89
93
|
base_url=base_url,
|
|
90
94
|
headers=headers,
|
|
@@ -92,7 +96,7 @@ class BaseRequesterKit:
|
|
|
92
96
|
auth=auth,
|
|
93
97
|
params=params,
|
|
94
98
|
timeout=timeout,
|
|
95
|
-
transport=AsyncHTTPTransport(retries=self._retryer_settings.retries),
|
|
99
|
+
transport=AsyncHTTPTransport(retries=self._retryer_settings.retries, verify=transport_verify),
|
|
96
100
|
)
|
|
97
101
|
self._retryer = AsyncRetrying(
|
|
98
102
|
stop=stop_after_attempt(self._retryer_settings.retries + 1),
|
|
@@ -251,11 +255,15 @@ class BaseRequesterKit:
|
|
|
251
255
|
try:
|
|
252
256
|
async for attempt in self._retryer:
|
|
253
257
|
with attempt:
|
|
254
|
-
response = await self._send_request(
|
|
258
|
+
response = await self._send_request(
|
|
259
|
+
request=request,
|
|
260
|
+
attempt_number=attempt.retry_state.attempt_number,
|
|
261
|
+
)
|
|
255
262
|
except RequesterKitRequestError as exc:
|
|
256
263
|
return RequesterKitResponse(
|
|
257
264
|
status_code=exc.status_code,
|
|
258
265
|
is_ok=False,
|
|
266
|
+
error_msg=str(exc),
|
|
259
267
|
)
|
|
260
268
|
|
|
261
269
|
if not response_model:
|
|
@@ -263,6 +271,8 @@ class BaseRequesterKit:
|
|
|
263
271
|
status_code=response.status_code,
|
|
264
272
|
is_ok=True,
|
|
265
273
|
raw_data=response.content,
|
|
274
|
+
headers=self._extract_response_headers(response),
|
|
275
|
+
cookies=self._extract_response_cookies(response),
|
|
266
276
|
)
|
|
267
277
|
|
|
268
278
|
try:
|
|
@@ -271,16 +281,25 @@ class BaseRequesterKit:
|
|
|
271
281
|
is_ok=True,
|
|
272
282
|
parsed_data=response_model.model_validate(response.json()),
|
|
273
283
|
raw_data=response.content,
|
|
284
|
+
headers=self._extract_response_headers(response),
|
|
285
|
+
cookies=self._extract_response_cookies(response),
|
|
274
286
|
)
|
|
275
287
|
except (ValidationError, JSONDecodeError) as exc:
|
|
276
288
|
self._logger.error("Unexpected response with error: %s", exc)
|
|
277
289
|
return RequesterKitResponse(
|
|
278
290
|
status_code=response.status_code,
|
|
279
291
|
is_ok=False,
|
|
292
|
+
error_msg=str(exc),
|
|
280
293
|
raw_data=response.content,
|
|
294
|
+
headers=self._extract_response_headers(response),
|
|
295
|
+
cookies=self._extract_response_cookies(response),
|
|
281
296
|
)
|
|
282
297
|
|
|
283
|
-
async def _send_request(
|
|
298
|
+
async def _send_request(
|
|
299
|
+
self,
|
|
300
|
+
request: Request,
|
|
301
|
+
attempt_number: int = 1,
|
|
302
|
+
) -> Response:
|
|
284
303
|
self._log_request(request)
|
|
285
304
|
|
|
286
305
|
start_time = time.perf_counter()
|
|
@@ -288,13 +307,13 @@ class BaseRequesterKit:
|
|
|
288
307
|
error_counter = None
|
|
289
308
|
request_size_metric = None
|
|
290
309
|
response_size_metric = None
|
|
310
|
+
metric_label = self._resolve_metric_label(request)
|
|
311
|
+
attempt_label = str(attempt_number)
|
|
291
312
|
if self._enable_prometheus_metrics:
|
|
292
313
|
metric = _get_prometheus_histogram(_PROM_REQUEST_DURATION_NAME)
|
|
293
314
|
error_counter = _get_prometheus_counter(_PROM_REQUEST_ERRORS_NAME)
|
|
294
315
|
request_size_metric = _get_prometheus_size_histogram(_PROM_REQUEST_SIZE_NAME)
|
|
295
316
|
response_size_metric = _get_prometheus_size_histogram(_PROM_RESPONSE_SIZE_NAME)
|
|
296
|
-
metric_label = self._resolve_metric_label(request)
|
|
297
|
-
attempt_label = str(attempt_number)
|
|
298
317
|
request_size_metric.labels(
|
|
299
318
|
method=metric_label,
|
|
300
319
|
status_code="request",
|
|
@@ -302,28 +321,15 @@ class BaseRequesterKit:
|
|
|
302
321
|
attempt=attempt_label,
|
|
303
322
|
).observe(len(request.content or b""))
|
|
304
323
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
method=metric_label,
|
|
315
|
-
status_code="exception",
|
|
316
|
-
status_class="error",
|
|
317
|
-
attempt=attempt_label,
|
|
318
|
-
).observe(duration)
|
|
319
|
-
if error_counter is not None:
|
|
320
|
-
error_counter.labels(
|
|
321
|
-
method=metric_label,
|
|
322
|
-
status_code="exception",
|
|
323
|
-
error_type="http_error",
|
|
324
|
-
attempt=attempt_label,
|
|
325
|
-
).inc()
|
|
326
|
-
raise RequesterKitRequestError(str(exc)) from exc
|
|
324
|
+
response = await self._send_http_request(
|
|
325
|
+
client=self._client,
|
|
326
|
+
request=request,
|
|
327
|
+
start_time=start_time,
|
|
328
|
+
metric=metric,
|
|
329
|
+
metric_label=metric_label,
|
|
330
|
+
attempt_label=attempt_label,
|
|
331
|
+
error_counter=error_counter,
|
|
332
|
+
)
|
|
327
333
|
|
|
328
334
|
duration = time.perf_counter() - start_time
|
|
329
335
|
if metric is not None:
|
|
@@ -355,6 +361,39 @@ class BaseRequesterKit:
|
|
|
355
361
|
|
|
356
362
|
return response
|
|
357
363
|
|
|
364
|
+
async def _send_http_request(
|
|
365
|
+
self,
|
|
366
|
+
client: AsyncClient,
|
|
367
|
+
request: Request,
|
|
368
|
+
start_time: float,
|
|
369
|
+
metric: Optional[Histogram],
|
|
370
|
+
metric_label: str,
|
|
371
|
+
attempt_label: str,
|
|
372
|
+
error_counter: Optional[Counter],
|
|
373
|
+
) -> Response:
|
|
374
|
+
try:
|
|
375
|
+
return await client.send(
|
|
376
|
+
request,
|
|
377
|
+
auth=self._client.auth,
|
|
378
|
+
)
|
|
379
|
+
except HTTPError as exc:
|
|
380
|
+
duration = time.perf_counter() - start_time
|
|
381
|
+
if metric is not None:
|
|
382
|
+
metric.labels(
|
|
383
|
+
method=metric_label,
|
|
384
|
+
status_code="exception",
|
|
385
|
+
status_class="error",
|
|
386
|
+
attempt=attempt_label,
|
|
387
|
+
).observe(duration)
|
|
388
|
+
if error_counter is not None:
|
|
389
|
+
error_counter.labels(
|
|
390
|
+
method=metric_label,
|
|
391
|
+
status_code="exception",
|
|
392
|
+
error_type="http_error",
|
|
393
|
+
attempt=attempt_label,
|
|
394
|
+
).inc()
|
|
395
|
+
raise RequesterKitRequestError(str(exc)) from exc
|
|
396
|
+
|
|
358
397
|
def _resolve_metric_label(self, request: Request) -> str:
|
|
359
398
|
frame = inspect.currentframe()
|
|
360
399
|
if frame is None:
|
|
@@ -402,3 +441,21 @@ class BaseRequesterKit:
|
|
|
402
441
|
extra["body"] = response.content.decode()
|
|
403
442
|
self._logger.warning(msg, extra=extra)
|
|
404
443
|
return
|
|
444
|
+
|
|
445
|
+
def _extract_response_headers(self, response: Response) -> dict[str, str]:
|
|
446
|
+
return dict(response.headers.items())
|
|
447
|
+
|
|
448
|
+
def _extract_response_cookies(self, response: Response) -> dict[str, str]:
|
|
449
|
+
try:
|
|
450
|
+
return dict(response.cookies.items())
|
|
451
|
+
except RuntimeError:
|
|
452
|
+
set_cookie_headers = response.headers.get_list("set-cookie")
|
|
453
|
+
if not set_cookie_headers:
|
|
454
|
+
return {}
|
|
455
|
+
cookies = {}
|
|
456
|
+
for set_cookie_header in set_cookie_headers:
|
|
457
|
+
parsed = SimpleCookie()
|
|
458
|
+
parsed.load(set_cookie_header)
|
|
459
|
+
for key, value in parsed.items():
|
|
460
|
+
cookies[key] = value.value
|
|
461
|
+
return cookies
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from collections.abc import Mapping, Sequence
|
|
4
|
+
from ssl import SSLContext
|
|
4
5
|
from typing import IO, Annotated, Any, Generic, Literal, Optional, TypeVar, Union
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -54,6 +55,7 @@ RequestJson = Union[dict[str, Any], list[dict[str, Any]]]
|
|
|
54
55
|
RequestContent = Union[str, bytes]
|
|
55
56
|
RequestCookies = dict[str, Any]
|
|
56
57
|
RequestAuth = tuple[str, str]
|
|
58
|
+
RequestVerify = Union[bool, str, SSLContext]
|
|
57
59
|
|
|
58
60
|
PositiveInt = Annotated[int, Field(strict=True, ge=0)]
|
|
59
61
|
PositiveFloat = Annotated[float, Field(strict=True, ge=0)]
|
|
@@ -62,8 +64,11 @@ PositiveFloat = Annotated[float, Field(strict=True, ge=0)]
|
|
|
62
64
|
class RequesterKitResponse(BaseModel, Generic[T_co]):
|
|
63
65
|
status_code: Optional[int] = None
|
|
64
66
|
is_ok: bool
|
|
67
|
+
error_msg: Optional[str] = None
|
|
65
68
|
parsed_data: Optional[T_co] = None
|
|
66
69
|
raw_data: bytes = b""
|
|
70
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
71
|
+
cookies: dict[str, str] = Field(default_factory=dict)
|
|
67
72
|
|
|
68
73
|
|
|
69
74
|
class BaseSettings(BaseModel):
|
|
@@ -500,6 +500,7 @@ async def test__base_async_requester__exception_during_send__turned_into_500(
|
|
|
500
500
|
mocker.patch.object(httpx.AsyncClient, "send", side_effect=httpx.HTTPError("Such error"))
|
|
501
501
|
response = await async_requester.get("http://localhost/hewwo")
|
|
502
502
|
assert not response.status_code
|
|
503
|
+
assert response.error_msg == "Such error"
|
|
503
504
|
|
|
504
505
|
|
|
505
506
|
async def test__base_async_requester__exception_during_build_request__raised(
|
|
@@ -700,6 +701,21 @@ async def test_no_data_validation(mock_httpx: MockHTTPX):
|
|
|
700
701
|
assert not response.parsed_data
|
|
701
702
|
|
|
702
703
|
|
|
704
|
+
async def test_response_contains_headers_and_cookies(mock_httpx: MockHTTPX):
|
|
705
|
+
mock_httpx(
|
|
706
|
+
200,
|
|
707
|
+
b'{"hello":"world"}',
|
|
708
|
+
headers={
|
|
709
|
+
"content-type": "application/json",
|
|
710
|
+
"set-cookie": "session_id=abc123; Path=/; HttpOnly",
|
|
711
|
+
},
|
|
712
|
+
)
|
|
713
|
+
response = await BaseRequesterKit().get("http://localhost/hewwo", response_model=HelloWorldModel)
|
|
714
|
+
|
|
715
|
+
assert response.headers["content-type"] == "application/json"
|
|
716
|
+
assert response.cookies["session_id"] == "abc123"
|
|
717
|
+
|
|
718
|
+
|
|
703
719
|
async def test_invalid_data_response(mock_httpx: MockHTTPX):
|
|
704
720
|
mock_httpx(200, b'{"bla":"blabla"}')
|
|
705
721
|
response = await BaseRequesterKit(
|
|
@@ -712,7 +728,28 @@ async def test_invalid_data_response(mock_httpx: MockHTTPX):
|
|
|
712
728
|
|
|
713
729
|
assert response.status_code == 200
|
|
714
730
|
assert not response.is_ok
|
|
731
|
+
assert response.error_msg is not None
|
|
715
732
|
|
|
716
733
|
|
|
717
734
|
async def test_async_requester_not_retry_unexpected_error():
|
|
718
735
|
assert BaseRequesterKit()._need_to_retry(ValueError) is False
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def test_base_requester_kit_passes_verify_to_transport(mocker: MockerFixture):
|
|
739
|
+
mocked_transport = mocker.patch("requester_kit.client.AsyncHTTPTransport")
|
|
740
|
+
BaseRequesterKit(verify=False)
|
|
741
|
+
|
|
742
|
+
mocked_transport.assert_called_once_with(retries=0, verify=False)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
async def test_base_requester_kit_send_request_uses_main_client(mocker: MockerFixture):
|
|
746
|
+
requester = BaseRequesterKit(verify=True)
|
|
747
|
+
request = httpx.Request(method="GET", url="http://localhost/hewwo")
|
|
748
|
+
|
|
749
|
+
main_send = mocker.patch.object(requester._client, "send")
|
|
750
|
+
main_send.return_value = httpx.Response(status_code=200, request=request, content=b"{}")
|
|
751
|
+
|
|
752
|
+
response = await requester._send_request(request=request)
|
|
753
|
+
|
|
754
|
+
assert response.status_code == 200
|
|
755
|
+
main_send.assert_awaited_once_with(request, auth=requester._client.auth)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|