http-misc 0.0.1__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.
http_misc/__init__.py ADDED
File without changes
http_misc/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ class RetryError(Exception):
2
+ """
3
+ Ошибка, которая вызывает повторный вызов запроса
4
+ """
5
+
6
+
7
+ class MaxRetryError(Exception):
8
+ """
9
+ Превышено максимальное число повторов
10
+ """
11
+
12
+
13
+ class InteractionError(Exception):
14
+ """ Ошибки взаимодействия с внешним сервисом """
15
+
16
+ def __init__(self, message, status_code: int | None = None, response: dict | None = None):
17
+ super().__init__(message)
18
+
19
+ self.status_code = status_code
20
+ self.response = response
@@ -0,0 +1,41 @@
1
+ import datetime
2
+ import decimal
3
+ import json
4
+ import uuid
5
+
6
+ from http_misc import services, errors
7
+
8
+
9
+ def default_encoder(obj):
10
+ """ Default JSON encoder """
11
+ if isinstance(obj, (datetime.date, datetime.datetime)):
12
+ return obj.isoformat()
13
+
14
+ if isinstance(obj, (uuid.UUID, decimal.Decimal)):
15
+ return str(obj)
16
+
17
+ return obj
18
+
19
+
20
+ def json_dumps(*args, **kwargs):
21
+ """ Сериализация в json """
22
+ return json.dumps(*args, **kwargs, default=default_encoder)
23
+
24
+
25
+ def join_str(*args, sep: str | None = '/', append_last_sep: bool | None = False) -> str:
26
+ """ Объединение строк """
27
+ args_str = [str(a) for a in args]
28
+ url = sep.join([arg.strip(sep) for arg in args_str])
29
+ if append_last_sep:
30
+ url = url + sep
31
+ return url
32
+
33
+
34
+ async def send_and_validate(service: services.BaseService, request, expected_status: int | None = 200):
35
+ """ Вызов внешнего сервиса и проверка его статуса"""
36
+ response = await service.send_request(**request)
37
+ if response.status != expected_status:
38
+ raise errors.InteractionError('Произошла ошибка при вызове внешнего сервиса',
39
+ status_code=response.status, response=response.response_data)
40
+
41
+ return response.response_data
http_misc/services.py ADDED
@@ -0,0 +1,237 @@
1
+ import asyncio
2
+ import logging
3
+ import random
4
+ import uuid
5
+ from abc import ABC, abstractmethod
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+
10
+ from aiohttp import ContentTypeError, ClientSession
11
+
12
+ from http_misc import http_utils, errors
13
+
14
+ DEFAULT_RETRY_ON_STATUSES = frozenset([413, 429, 503, 504])
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class ServiceResponse:
20
+ """ Ответ сервиса """
21
+ status: int
22
+ response_data: any = None
23
+ raw_response: any = None
24
+
25
+
26
+ class OnErrorResult(Enum):
27
+ """
28
+ SILENT - проигнорировать ошибку
29
+ THROW - возбудить ошибку
30
+ REFRESH - при ошибке выполнить действие повторно
31
+ """
32
+ SILENT = 1
33
+ THROW = 2
34
+ REFRESH = 3
35
+
36
+
37
+ @dataclass
38
+ class RetryPolicy:
39
+ max_retry: int | None = 10
40
+ retry_on_statuses: set[int] | None = DEFAULT_RETRY_ON_STATUSES
41
+ backoff_factor: int | None = 0.3
42
+ jitter: int | None = 0.1
43
+
44
+
45
+ class Transformer(ABC):
46
+ @abstractmethod
47
+ async def modify(self, request_id, *args, **kwargs):
48
+ """ Изменение параметров запроса или ответа """
49
+ return args, kwargs
50
+
51
+
52
+ class BaseService(ABC):
53
+ """
54
+ Abstract service
55
+ """
56
+
57
+ def __init__(self, retry_policy: RetryPolicy | None = None,
58
+ request_preproc: list[Transformer] | None = None,
59
+ response_preproc: list[Transformer] | None = None):
60
+ """ Сервис """
61
+ self.retry_policy = retry_policy or RetryPolicy()
62
+ self.request_preproc = request_preproc
63
+ self.response_preproc = response_preproc
64
+ self._request_count_manager = _RequestCountManager()
65
+
66
+ @abstractmethod
67
+ async def _send(self, request_id: uuid.UUID, *args, **kwargs) -> ServiceResponse:
68
+ """
69
+ Abstract _send
70
+ """
71
+ raise NotImplementedError('Not implemented _send method')
72
+
73
+ async def send_request(self, *args, **kwargs):
74
+ """ Вызов внешнего сервиса """
75
+ request_id = self._request_count_manager.add()
76
+ try:
77
+ return await self._send_request(request_id, *args, **kwargs)
78
+ finally:
79
+ # обнуляем current_step для запроса, он выполнен успешно
80
+ self._request_count_manager.pop(request_id)
81
+
82
+ def get_current_step(self, request_id: uuid.UUID):
83
+ """ Получение количества попыток запроса """
84
+ return self._request_count_manager.get(request_id)
85
+
86
+ async def _send_request(self, request_id: uuid.UUID, *args, **kwargs) -> ServiceResponse:
87
+ """
88
+ Вызов внешнего сервиса
89
+ """
90
+ try:
91
+ args, kwargs = await self._before_send(request_id, *args, **kwargs)
92
+ logger.debug('Send request %s: %s; %s', self.get_current_step(request_id), args, kwargs)
93
+ service_response = await self._send(request_id, *args, **kwargs)
94
+ service_response = await self._transform_response(request_id, service_response)
95
+ logger.debug('Response %s: %s, %s', self.get_current_step(request_id),
96
+ service_response.status, service_response.response_data)
97
+
98
+ if self.retry_policy.retry_on_statuses and service_response.status in self.retry_policy.retry_on_statuses:
99
+ raise errors.RetryError()
100
+
101
+ return service_response
102
+ except Exception as ex:
103
+ if isinstance(ex, errors.RetryError):
104
+ on_error_result = OnErrorResult.REFRESH
105
+ else:
106
+ on_error_result = await self._on_error(ex, *args, **kwargs)
107
+
108
+ if on_error_result == OnErrorResult.THROW:
109
+ logger.exception(ex)
110
+ raise ex
111
+
112
+ if on_error_result == OnErrorResult.SILENT:
113
+ logger.exception(ex)
114
+ return ServiceResponse(status=-1)
115
+
116
+ if on_error_result == OnErrorResult.REFRESH:
117
+ return await self._resend_request(request_id, ex, *args, **kwargs)
118
+
119
+ raise NotImplementedError(f'on_error_result "{on_error_result}" not implemented') from ex
120
+
121
+ async def _transform_response(self, request_id: uuid.UUID, response: ServiceResponse) -> ServiceResponse:
122
+ """ Преобразование ответа для возврата пользователю """
123
+ if self.response_preproc:
124
+ for response_preproc in self.response_preproc:
125
+ response = await response_preproc.modify(request_id, response)
126
+ return response
127
+
128
+ async def _before_send(self, request_id: uuid.UUID, *args, **kwargs):
129
+ """ Действие перед вызовом """
130
+ if self.request_preproc:
131
+ for request_preproc in self.request_preproc:
132
+ args, kwargs = await request_preproc.modify(request_id, *args, **kwargs)
133
+ return args, kwargs
134
+
135
+ async def _on_error(self, ex: Exception, *args, **kwargs) -> OnErrorResult: # pylint: disable=unused-argument
136
+ """
137
+ Действие на возникновение ошибки.
138
+ SILENT - продолжить работу без возникновения ошибки
139
+ THROW - выкинуть исключение дальше
140
+ REFRESH - сделать повторный вызов сервиса
141
+ """
142
+ return OnErrorResult.THROW
143
+
144
+ async def _resend_request(self, request_id: uuid.UUID, ex: Exception, *args, **kwargs):
145
+ """ Повторная отправка запроса """
146
+ self._request_count_manager.inc(request_id)
147
+
148
+ current_step = self.get_current_step(request_id)
149
+ logger.debug('Repeat request #%s.', current_step)
150
+
151
+ if current_step >= self.retry_policy.max_retry:
152
+ raise errors.MaxRetryError(f'Exceeded the maximum number of attempts {self.retry_policy.max_retry}') from ex
153
+
154
+ sleep_seconds = self.retry_policy.backoff_factor * (2 ** (current_step - 1))
155
+ sleep_seconds += random.normalvariate(0, sleep_seconds * self.retry_policy.jitter)
156
+ await asyncio.sleep(sleep_seconds)
157
+
158
+ return await self._send_request(request_id, *args, **kwargs)
159
+
160
+
161
+ class HttpService(BaseService):
162
+ """
163
+ Вызов по протоколу http
164
+ """
165
+
166
+ def __init__(self, *args, client_session: ClientSession | None = None, **kwargs):
167
+ super().__init__(*args, **kwargs)
168
+ self.client_session = client_session
169
+
170
+ async def _send(self, request_id: uuid.UUID, *args, **kwargs) -> ServiceResponse:
171
+ method = kwargs.get('method', 'get')
172
+ url = kwargs.get('url', None)
173
+ if url is None:
174
+ raise ValueError('Url is none')
175
+ url = str(url)
176
+
177
+ cfg = kwargs.get('cfg', {})
178
+ if not isinstance(cfg, dict):
179
+ raise ValueError('Invalid cfg type. Must be dict.')
180
+
181
+ if url.lower().startswith('https://') and 'ssl' not in cfg:
182
+ cfg['ssl'] = False
183
+
184
+ async with self._use_client_session() as session:
185
+ async with session.request(method, url, **cfg) as response:
186
+ response_data = await _get_response_content(response)
187
+ return ServiceResponse(status=response.status, response_data=response_data, raw_response=response)
188
+
189
+ @asynccontextmanager
190
+ async def _use_client_session(self):
191
+ if self.client_session is not None:
192
+ yield self.client_session
193
+ else:
194
+ async with ClientSession(json_serialize=http_utils.json_dumps) as session:
195
+ yield session
196
+
197
+
198
+ class _RequestCountManager:
199
+ def __init__(self):
200
+ self._requests: dict[uuid.UUID, int] = {}
201
+
202
+ def add(self) -> uuid.UUID:
203
+ """ Инициализация запроса """
204
+ request_id = uuid.uuid4()
205
+ self._requests[request_id] = 0
206
+ return request_id
207
+
208
+ def exist(self, request_id: uuid.UUID) -> bool:
209
+ """ Проверка наличия запроса """
210
+ if request_id not in self._requests:
211
+ raise KeyError(f'Request {str(request_id)} not in registry')
212
+
213
+ return True
214
+
215
+ def pop(self, request_id: uuid.UUID) -> int | None:
216
+ """ Удаление запроса """
217
+ self.exist(request_id)
218
+ return self._requests.pop(request_id)
219
+
220
+ def get(self, request_id: uuid.UUID) -> int:
221
+ """ Получение количества попыток запроса """
222
+ self.exist(request_id)
223
+ return self._requests[request_id]
224
+
225
+ def inc(self, request_id: uuid.UUID) -> int:
226
+ """ Увеличение количества попыток на 1 """
227
+ self.exist(request_id)
228
+ self._requests[request_id] += 1
229
+
230
+ return self._requests[request_id]
231
+
232
+
233
+ async def _get_response_content(response):
234
+ try:
235
+ return await response.json()
236
+ except ContentTypeError:
237
+ return await response.text()
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: http-misc
3
+ Version: 0.0.1
4
+ Summary: Утилитарный пакет межсервисного взаимодействия по протоколу HTTP
5
+ Author: Anton Gorinenko
6
+ Author-email: anton.gorinenko@gmail.com
7
+ Keywords: python,utils,http
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: aiohttp
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Requires-Dist: python-dotenv; extra == "test"
19
+ Requires-Dist: envparse; extra == "test"
20
+ Requires-Dist: pytest-asyncio; extra == "test"
21
+ Dynamic: author
22
+ Dynamic: author-email
23
+ Dynamic: classifier
24
+ Dynamic: description-content-type
25
+ Dynamic: keywords
26
+ Dynamic: provides-extra
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
@@ -0,0 +1,8 @@
1
+ http_misc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ http_misc/errors.py,sha256=pccca5bNrK0tsD43aJt6S9QRnafRU8VQIsJxok-yqmo,595
3
+ http_misc/http_utils.py,sha256=zhYiClCdu5CVyxwFKDh8uEFO762io-VtbA3gmC_0d54,1339
4
+ http_misc/services.py,sha256=sJx71UXZQR3aTfqJChkIK3GiIoIl2bp6JUDvjr3Mg9o,8968
5
+ http_misc-0.0.1.dist-info/METADATA,sha256=UFxUik6ccugw7NPTfHrpqs2PQks9rBBlwd06On3dU20,1004
6
+ http_misc-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ http_misc-0.0.1.dist-info/top_level.txt,sha256=NqFQPUsdTz2f-J8Ku-ghmWbGpqletRoCk8ayXRevDBY,10
8
+ http_misc-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ http_misc