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 +0 -0
- http_misc/errors.py +20 -0
- http_misc/http_utils.py +41 -0
- http_misc/services.py +237 -0
- http_misc-0.0.1.dist-info/METADATA +29 -0
- http_misc-0.0.1.dist-info/RECORD +8 -0
- http_misc-0.0.1.dist-info/WHEEL +5 -0
- http_misc-0.0.1.dist-info/top_level.txt +1 -0
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
|
http_misc/http_utils.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
http_misc
|