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.
@@ -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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "requester-kit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  license = {text = "MIT"}
5
5
  description = "Async HTTP connector toolkit with retries, testing helpers, and Prometheus metrics."
6
6
  readme = "README.md"
@@ -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(request, attempt.retry_state.attempt_number)
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(self, request: Request, attempt_number: int = 1) -> Response:
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
- try:
306
- response = await self._client.send(
307
- request,
308
- auth=self._client.auth,
309
- )
310
- except HTTPError as exc:
311
- duration = time.perf_counter() - start_time
312
- if metric is not None:
313
- metric.labels(
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)
@@ -1059,7 +1059,7 @@ wheels = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "requester-kit"
1062
- version = "0.3.0"
1062
+ version = "0.4.0"
1063
1063
  source = { editable = "." }
1064
1064
  dependencies = [
1065
1065
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes