tp-common 0.0.1__py3-none-any.whl → 0.0.3__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.
- tp_common/base_client/{base_client.py → client.py} +585 -585
- tp_common/base_client/exceptions.py +138 -0
- tp_common/{base_client/base_request.py → route/shames.py} +17 -12
- {tp_common-0.0.1.dist-info → tp_common-0.0.3.dist-info}/METADATA +1 -1
- tp_common-0.0.3.dist-info/RECORD +6 -0
- tp_common/__init__.py +0 -52
- tp_common/base_client/__init__.py +0 -0
- tp_common/base_client/base_exception.py +0 -2
- tp_common/base_client/base_response.py +0 -11
- tp_common/base_client/client_exceptions.py +0 -76
- tp_common/base_client/domain_exceptions.py +0 -63
- tp_common/logging.py +0 -339
- tp_common-0.0.1.dist-info/RECORD +0 -12
- {tp_common-0.0.1.dist-info → tp_common-0.0.3.dist-info}/WHEEL +0 -0
|
@@ -1,585 +1,585 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json as json_lib
|
|
3
|
-
import logging
|
|
4
|
-
import time
|
|
5
|
-
from datetime import UTC, datetime
|
|
6
|
-
from typing import Any, Literal, TypeVar
|
|
7
|
-
from urllib.parse import urlencode
|
|
8
|
-
|
|
9
|
-
import aiohttp
|
|
10
|
-
from aiohttp import (
|
|
11
|
-
ClientConnectionError,
|
|
12
|
-
ClientConnectorDNSError,
|
|
13
|
-
ClientHttpProxyError,
|
|
14
|
-
ClientProxyConnectionError,
|
|
15
|
-
ServerTimeoutError,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
from tp_common.
|
|
19
|
-
BaseClientException,
|
|
20
|
-
ClientConnectionException,
|
|
21
|
-
ClientDNSException,
|
|
22
|
-
ClientProxyException,
|
|
23
|
-
ClientResponseErrorException,
|
|
24
|
-
ClientTimeoutException,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
T = TypeVar("T", bound="BaseClient")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class BaseClient:
|
|
31
|
-
"""
|
|
32
|
-
Базовый HTTP клиент для работы с внешними API.
|
|
33
|
-
|
|
34
|
-
Поддерживает:
|
|
35
|
-
- Переиспользование сессии
|
|
36
|
-
- Прокси
|
|
37
|
-
- Cookies
|
|
38
|
-
- Имитацию браузера
|
|
39
|
-
- Логирование (с автоматическим созданием логгера при необходимости)
|
|
40
|
-
- Переопределение базового URL для тестирования
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
cookies: Строка с cookies в формате "name=value; name2=value2"
|
|
44
|
-
proxy: URL прокси-сервера
|
|
45
|
-
logger: Внешний логгер (если не передан, создается автоматически)
|
|
46
|
-
base_url: Базовый URL для запросов (обязательный параметр)
|
|
47
|
-
"""
|
|
48
|
-
|
|
49
|
-
DEFAULT_TIMEOUT = 30.0 # секунды
|
|
50
|
-
|
|
51
|
-
def __init__(
|
|
52
|
-
self,
|
|
53
|
-
base_url: str,
|
|
54
|
-
cookies: str | None = None,
|
|
55
|
-
proxy: str | None = None,
|
|
56
|
-
logger: logging.Logger | None = None,
|
|
57
|
-
):
|
|
58
|
-
self._raw_cookies = cookies
|
|
59
|
-
self._proxy: str | None = proxy
|
|
60
|
-
self._session: aiohttp.ClientSession | None = None
|
|
61
|
-
self._logger = logger or self._create_logger()
|
|
62
|
-
self._base_url = base_url
|
|
63
|
-
|
|
64
|
-
def _create_logger(self) -> logging.Logger:
|
|
65
|
-
"""Создает логгер на основе имени класса, если не передан внешний."""
|
|
66
|
-
class_name = self.__class__.__name__
|
|
67
|
-
module_name = self.__class__.__module__
|
|
68
|
-
logger_name = f"{module_name}.{class_name}"
|
|
69
|
-
return logging.getLogger(logger_name)
|
|
70
|
-
|
|
71
|
-
@property
|
|
72
|
-
def logger(self) -> logging.Logger:
|
|
73
|
-
"""Возвращает логгер для использования в наследниках."""
|
|
74
|
-
return self._logger
|
|
75
|
-
|
|
76
|
-
@logger.setter
|
|
77
|
-
def logger(self, logger: logging.Logger) -> None:
|
|
78
|
-
self._logger = logger
|
|
79
|
-
|
|
80
|
-
async def __aenter__(self: T) -> T:
|
|
81
|
-
"""Инициализация сессии при входе в контекстный менеджер"""
|
|
82
|
-
_ = await self._get_session()
|
|
83
|
-
return self
|
|
84
|
-
|
|
85
|
-
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
86
|
-
"""Закрытие сессии при выходе из контекстного менеджера"""
|
|
87
|
-
await self.close()
|
|
88
|
-
|
|
89
|
-
async def _get_session(self) -> aiohttp.ClientSession:
|
|
90
|
-
"""Получает или создает переиспользуемую сессию"""
|
|
91
|
-
if self._session is None or self._session.closed:
|
|
92
|
-
timeout = aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
|
|
93
|
-
connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
|
|
94
|
-
|
|
95
|
-
self._session = aiohttp.ClientSession(
|
|
96
|
-
timeout=timeout,
|
|
97
|
-
connector=connector,
|
|
98
|
-
cookies=self._prepare_cookies_dict(),
|
|
99
|
-
)
|
|
100
|
-
return self._session
|
|
101
|
-
|
|
102
|
-
async def close(self) -> None:
|
|
103
|
-
"""Закрывает HTTP сессию"""
|
|
104
|
-
if self._session and not self._session.closed:
|
|
105
|
-
await self._session.close()
|
|
106
|
-
self._session = None
|
|
107
|
-
|
|
108
|
-
def _get_default_headers(self) -> dict[str, str]:
|
|
109
|
-
"""Возвращает заголовки по умолчанию для имитации браузера"""
|
|
110
|
-
return {
|
|
111
|
-
"accept": "*/*",
|
|
112
|
-
"accept-language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
113
|
-
"sec-ch-ua": '"Chromium";v="122"',
|
|
114
|
-
"sec-ch-ua-mobile": "?0",
|
|
115
|
-
"sec-fetch-dest": "empty",
|
|
116
|
-
"sec-fetch-mode": "cors",
|
|
117
|
-
"sec-fetch-site": "same-origin",
|
|
118
|
-
"referer": f"{self._base_url}/",
|
|
119
|
-
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
@property
|
|
123
|
-
def cookies(self) -> str | None:
|
|
124
|
-
"""Возвращает текущие cookies"""
|
|
125
|
-
return self._raw_cookies
|
|
126
|
-
|
|
127
|
-
@cookies.setter
|
|
128
|
-
def cookies(self, cookies: str) -> None:
|
|
129
|
-
"""Устанавливает новые cookies (требует пересоздания сессии)"""
|
|
130
|
-
self._raw_cookies = cookies
|
|
131
|
-
# Сразу обнуляем _session, чтобы следующий _get_session() создал новую сессию
|
|
132
|
-
# с новыми cookies; старую сессию закрываем в фоне (иначе гонка: запрос
|
|
133
|
-
# может получить уже закрываемую сессию → "Connector is closed")
|
|
134
|
-
old_session = self._session
|
|
135
|
-
self._session = None
|
|
136
|
-
if old_session and not old_session.closed:
|
|
137
|
-
|
|
138
|
-
async def _close_old() -> None:
|
|
139
|
-
await old_session.close()
|
|
140
|
-
|
|
141
|
-
asyncio.create_task(_close_old())
|
|
142
|
-
|
|
143
|
-
@property
|
|
144
|
-
def proxy(self) -> str | None:
|
|
145
|
-
"""Возвращает текущий прокси"""
|
|
146
|
-
return self._proxy
|
|
147
|
-
|
|
148
|
-
@proxy.setter
|
|
149
|
-
def proxy(self, proxy: str) -> None:
|
|
150
|
-
"""Устанавливает новый прокси"""
|
|
151
|
-
self._proxy = proxy
|
|
152
|
-
|
|
153
|
-
def _prepare_cookies_dict(self) -> dict[str, str] | None:
|
|
154
|
-
"""Подготавливает словарь cookies из строки для aiohttp"""
|
|
155
|
-
if not self._raw_cookies:
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
cookies_dict: dict[str, str] = {}
|
|
159
|
-
|
|
160
|
-
# Парсим строку cookies в формате "name=value; name2=value2"
|
|
161
|
-
for cookie_str in self._raw_cookies.split(";"):
|
|
162
|
-
cookie_str = cookie_str.strip()
|
|
163
|
-
if not cookie_str:
|
|
164
|
-
continue
|
|
165
|
-
if "=" in cookie_str:
|
|
166
|
-
name, value = cookie_str.split("=", 1)
|
|
167
|
-
cookies_dict[name.strip()] = value.strip()
|
|
168
|
-
|
|
169
|
-
return cookies_dict if cookies_dict else None
|
|
170
|
-
|
|
171
|
-
def extract_cookies_from_session(self) -> str | None:
|
|
172
|
-
"""
|
|
173
|
-
Извлекает cookies из текущей сессии в строку формата 'name=value; name2=value2'.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
Строка с кукисами в формате 'name=value; name2=value2' или None
|
|
177
|
-
"""
|
|
178
|
-
if not self._session or self._session.closed:
|
|
179
|
-
return self._raw_cookies
|
|
180
|
-
|
|
181
|
-
cookies_list: list[str] = []
|
|
182
|
-
for cookie in self._session.cookie_jar:
|
|
183
|
-
cookies_list.append(f"{cookie.key}={cookie.value}")
|
|
184
|
-
|
|
185
|
-
if not cookies_list:
|
|
186
|
-
return self._raw_cookies
|
|
187
|
-
|
|
188
|
-
return "; ".join(cookies_list)
|
|
189
|
-
|
|
190
|
-
def _build_url(self, uri: str, params: str | dict[str, Any] | None = None) -> str:
|
|
191
|
-
"""Строит полный URL из базового адреса, URI и параметров"""
|
|
192
|
-
url = f"{self._base_url}/{uri.lstrip('/')}"
|
|
193
|
-
|
|
194
|
-
if params and isinstance(params, dict):
|
|
195
|
-
# Фильтруем None значения и конвертируем все значения в строки для urlencode
|
|
196
|
-
filtered_params: dict[str, str] = {}
|
|
197
|
-
for key, val in params.items():
|
|
198
|
-
if val is not None:
|
|
199
|
-
# Для datetime объектов используем ISO формат с Z и миллисекундами
|
|
200
|
-
if isinstance(val, datetime):
|
|
201
|
-
# Формат: 2026-01-20T00:00:00.000Z
|
|
202
|
-
# Если datetime имеет timezone, конвертируем в UTC, иначе используем как есть
|
|
203
|
-
if val.tzinfo is not None:
|
|
204
|
-
# Конвертируем в UTC
|
|
205
|
-
val_utc = val.astimezone(UTC)
|
|
206
|
-
filtered_params[key] = (
|
|
207
|
-
val_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
208
|
-
)
|
|
209
|
-
else:
|
|
210
|
-
# Naive datetime - используем как есть и добавляем Z
|
|
211
|
-
filtered_params[key] = (
|
|
212
|
-
val.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
213
|
-
)
|
|
214
|
-
else:
|
|
215
|
-
filtered_params[key] = str(val)
|
|
216
|
-
if filtered_params:
|
|
217
|
-
# Используем urlencode для правильного кодирования параметров URL
|
|
218
|
-
query_string = urlencode(filtered_params, doseq=False)
|
|
219
|
-
url += "?" + query_string
|
|
220
|
-
elif params and isinstance(params, str):
|
|
221
|
-
url += f"?{params}"
|
|
222
|
-
|
|
223
|
-
return url
|
|
224
|
-
|
|
225
|
-
def _merge_headers(self, custom_headers: dict[str, Any] | None) -> dict[str, str]:
|
|
226
|
-
"""Объединяет заголовки по умолчанию с пользовательскими"""
|
|
227
|
-
headers = self._get_default_headers()
|
|
228
|
-
if custom_headers:
|
|
229
|
-
headers.update(custom_headers)
|
|
230
|
-
return headers
|
|
231
|
-
|
|
232
|
-
def _prepare_payload(
|
|
233
|
-
self, json: dict[str, Any] | None, data: Any | None
|
|
234
|
-
) -> dict[str, Any] | list[Any] | str | None:
|
|
235
|
-
"""Подготавливает payload для логирования"""
|
|
236
|
-
if json is not None:
|
|
237
|
-
return json
|
|
238
|
-
if data is not None:
|
|
239
|
-
if isinstance(data, str):
|
|
240
|
-
try:
|
|
241
|
-
return json_lib.loads(data)
|
|
242
|
-
except (json_lib.JSONDecodeError, TypeError):
|
|
243
|
-
return None
|
|
244
|
-
elif isinstance(data, (dict, list)):
|
|
245
|
-
return data
|
|
246
|
-
return None
|
|
247
|
-
return None
|
|
248
|
-
|
|
249
|
-
def _prepare_response(self, response_text: str) -> dict[str, Any] | list[Any] | str:
|
|
250
|
-
"""Подготавливает response для логирования: пытается распарсить JSON, иначе возвращает текст"""
|
|
251
|
-
if not response_text:
|
|
252
|
-
return response_text
|
|
253
|
-
try:
|
|
254
|
-
return json_lib.loads(response_text)
|
|
255
|
-
except (json_lib.JSONDecodeError, TypeError):
|
|
256
|
-
return response_text
|
|
257
|
-
|
|
258
|
-
def _decode_response_body(
|
|
259
|
-
self, body_bytes: bytes, response: aiohttp.ClientResponse
|
|
260
|
-
) -> str:
|
|
261
|
-
"""
|
|
262
|
-
Декодирует тело ответа в строку с учётом кодировки из заголовков
|
|
263
|
-
и резервных кодировок при ошибке UTF-8 (например, cp1251 для российских API).
|
|
264
|
-
"""
|
|
265
|
-
encodings_to_try: list[str | tuple[str, str]] = []
|
|
266
|
-
if response.charset:
|
|
267
|
-
encodings_to_try.append(response.charset)
|
|
268
|
-
encodings_to_try.append("utf-8")
|
|
269
|
-
encodings_to_try.append("cp1251") # Windows-1251, типично для российских API
|
|
270
|
-
encodings_to_try.append(("iso-8859-1", "replace"))
|
|
271
|
-
for entry in encodings_to_try:
|
|
272
|
-
if isinstance(entry, tuple):
|
|
273
|
-
encoding, errors = entry
|
|
274
|
-
else:
|
|
275
|
-
encoding, errors = entry, "strict"
|
|
276
|
-
try:
|
|
277
|
-
return body_bytes.decode(encoding, errors=errors)
|
|
278
|
-
except (UnicodeDecodeError, LookupError):
|
|
279
|
-
continue
|
|
280
|
-
return body_bytes.decode("iso-8859-1", errors="replace")
|
|
281
|
-
|
|
282
|
-
async def _make_request(
|
|
283
|
-
self,
|
|
284
|
-
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
285
|
-
uri: str,
|
|
286
|
-
headers: dict[str, Any] | None = None,
|
|
287
|
-
params: str | dict[str, Any] | None = None,
|
|
288
|
-
json: dict[str, Any] | None = None,
|
|
289
|
-
data: Any | None = None,
|
|
290
|
-
retry_on_proxy_error: bool = False,
|
|
291
|
-
max_retries: int = 5,
|
|
292
|
-
) -> str:
|
|
293
|
-
"""
|
|
294
|
-
Базовый метод для выполнения HTTP запросов. Возвращает только строку.
|
|
295
|
-
|
|
296
|
-
Args:
|
|
297
|
-
method: HTTP метод
|
|
298
|
-
uri: URI endpoint (будет добавлен к base_url)
|
|
299
|
-
headers: Дополнительные заголовки
|
|
300
|
-
params: Query параметры
|
|
301
|
-
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
302
|
-
data: Данные тела запроса (альтернатива json)
|
|
303
|
-
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
304
|
-
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
305
|
-
|
|
306
|
-
Returns:
|
|
307
|
-
Текст ответа как строка
|
|
308
|
-
|
|
309
|
-
Raises:
|
|
310
|
-
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
311
|
-
ClientTimeoutException: При таймауте соединения
|
|
312
|
-
ClientProxyException: При ошибке прокси
|
|
313
|
-
ClientConnectionException: При ошибке соединения
|
|
314
|
-
ClientDNSException: При ошибке DNS
|
|
315
|
-
"""
|
|
316
|
-
url = self._build_url(uri, params)
|
|
317
|
-
merged_headers = self._merge_headers(headers)
|
|
318
|
-
session = await self._get_session()
|
|
319
|
-
|
|
320
|
-
# Определяем kwargs для запроса
|
|
321
|
-
request_kwargs: dict[str, Any] = {
|
|
322
|
-
"url": url,
|
|
323
|
-
"headers": merged_headers,
|
|
324
|
-
"proxy": self._proxy,
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if json is not None:
|
|
328
|
-
request_kwargs["json"] = json
|
|
329
|
-
elif data is not None:
|
|
330
|
-
request_kwargs["data"] = data
|
|
331
|
-
|
|
332
|
-
payload = self._prepare_payload(json, data)
|
|
333
|
-
amount_failed = 0
|
|
334
|
-
|
|
335
|
-
while True:
|
|
336
|
-
response_body_text = ""
|
|
337
|
-
start_time = time.perf_counter()
|
|
338
|
-
try:
|
|
339
|
-
async with session.request(method, **request_kwargs) as response:
|
|
340
|
-
body_bytes = await response.read()
|
|
341
|
-
response_body_text = self._decode_response_body(
|
|
342
|
-
body_bytes, response
|
|
343
|
-
)
|
|
344
|
-
end_time = time.perf_counter()
|
|
345
|
-
duration_ms = int((end_time - start_time) * 1000)
|
|
346
|
-
|
|
347
|
-
response_data = self._prepare_response(response_body_text)
|
|
348
|
-
|
|
349
|
-
self._logger.info(
|
|
350
|
-
"HTTP запрос выполнен",
|
|
351
|
-
extra={
|
|
352
|
-
"url": url,
|
|
353
|
-
"method": method,
|
|
354
|
-
"status_code": response.status,
|
|
355
|
-
"payload": payload,
|
|
356
|
-
"response": response_data,
|
|
357
|
-
"duration": duration_ms,
|
|
358
|
-
},
|
|
359
|
-
)
|
|
360
|
-
|
|
361
|
-
if response.status >= 400:
|
|
362
|
-
raise ClientResponseErrorException(
|
|
363
|
-
f"HTTP {response.status} error: {response.reason}",
|
|
364
|
-
url=url,
|
|
365
|
-
status_code=response.status,
|
|
366
|
-
response_body=response_body_text,
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
return response_body_text
|
|
370
|
-
|
|
371
|
-
except (ClientHttpProxyError, ClientProxyConnectionError) as e:
|
|
372
|
-
if not retry_on_proxy_error:
|
|
373
|
-
self._logger.error(
|
|
374
|
-
"Ошибка прокси при HTTP запросе",
|
|
375
|
-
extra={
|
|
376
|
-
"method": method,
|
|
377
|
-
"url": url,
|
|
378
|
-
"proxy": self._proxy,
|
|
379
|
-
"error": str(e),
|
|
380
|
-
},
|
|
381
|
-
)
|
|
382
|
-
raise ClientProxyException(
|
|
383
|
-
f"Proxy error: {str(e)}",
|
|
384
|
-
url=url,
|
|
385
|
-
proxy=self._proxy,
|
|
386
|
-
) from e
|
|
387
|
-
|
|
388
|
-
amount_failed += 1
|
|
389
|
-
if amount_failed >= max_retries:
|
|
390
|
-
self._logger.error(
|
|
391
|
-
"Ошибка прокси после исчерпания попыток",
|
|
392
|
-
extra={
|
|
393
|
-
"method": method,
|
|
394
|
-
"url": url,
|
|
395
|
-
"proxy": self._proxy,
|
|
396
|
-
"retries": amount_failed,
|
|
397
|
-
"error": str(e),
|
|
398
|
-
},
|
|
399
|
-
)
|
|
400
|
-
raise ClientProxyException(
|
|
401
|
-
f"Proxy error after {max_retries} retries: {str(e)}",
|
|
402
|
-
url=url,
|
|
403
|
-
proxy=self._proxy,
|
|
404
|
-
) from e
|
|
405
|
-
|
|
406
|
-
self._logger.warning(
|
|
407
|
-
"Повтор запроса после ошибки прокси",
|
|
408
|
-
extra={
|
|
409
|
-
"method": method,
|
|
410
|
-
"url": url,
|
|
411
|
-
"proxy": self._proxy,
|
|
412
|
-
"retry": amount_failed,
|
|
413
|
-
"max_retries": max_retries,
|
|
414
|
-
},
|
|
415
|
-
)
|
|
416
|
-
continue
|
|
417
|
-
|
|
418
|
-
except (TimeoutError, ServerTimeoutError) as e:
|
|
419
|
-
# self._logger.error(
|
|
420
|
-
# "Таймаут HTTP запроса",
|
|
421
|
-
# extra={
|
|
422
|
-
# "method": method,
|
|
423
|
-
# "url": url,
|
|
424
|
-
# "timeout": self.DEFAULT_TIMEOUT,
|
|
425
|
-
# "error": str(e),
|
|
426
|
-
# },
|
|
427
|
-
# )
|
|
428
|
-
raise ClientTimeoutException(
|
|
429
|
-
f"Request timeout after {self.DEFAULT_TIMEOUT}s: {str(e)}",
|
|
430
|
-
url=url,
|
|
431
|
-
) from e
|
|
432
|
-
|
|
433
|
-
except ClientConnectorDNSError as e:
|
|
434
|
-
self._logger.error(
|
|
435
|
-
"Ошибка DNS при HTTP запросе",
|
|
436
|
-
extra={
|
|
437
|
-
"method": method,
|
|
438
|
-
"url": url,
|
|
439
|
-
"error": str(e),
|
|
440
|
-
},
|
|
441
|
-
)
|
|
442
|
-
raise ClientDNSException(
|
|
443
|
-
f"DNS resolution failed: {str(e)}",
|
|
444
|
-
url=url,
|
|
445
|
-
) from e
|
|
446
|
-
|
|
447
|
-
except ClientConnectionError as e:
|
|
448
|
-
self._logger.error(
|
|
449
|
-
"Ошибка соединения при HTTP запросе",
|
|
450
|
-
extra={
|
|
451
|
-
"method": method,
|
|
452
|
-
"url": url,
|
|
453
|
-
"error": str(e),
|
|
454
|
-
},
|
|
455
|
-
)
|
|
456
|
-
raise ClientConnectionException(
|
|
457
|
-
f"Connection error: {str(e)}",
|
|
458
|
-
url=url,
|
|
459
|
-
) from e
|
|
460
|
-
except ClientResponseErrorException as e:
|
|
461
|
-
raise e
|
|
462
|
-
except Exception as e:
|
|
463
|
-
# Неожиданные ошибки
|
|
464
|
-
self._logger.error(
|
|
465
|
-
"Неожиданная ошибка HTTP запроса",
|
|
466
|
-
extra={
|
|
467
|
-
"method": method,
|
|
468
|
-
"url": url,
|
|
469
|
-
"error_type": type(e).__name__,
|
|
470
|
-
"error": str(e),
|
|
471
|
-
"response": response_body_text or "",
|
|
472
|
-
},
|
|
473
|
-
)
|
|
474
|
-
raise BaseClientException(
|
|
475
|
-
f"Неожиданная HTTP-ошибка: {str(e)}",
|
|
476
|
-
url=url,
|
|
477
|
-
) from e
|
|
478
|
-
|
|
479
|
-
async def request_text(
|
|
480
|
-
self,
|
|
481
|
-
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
482
|
-
uri: str,
|
|
483
|
-
headers: dict[str, Any] | None = None,
|
|
484
|
-
params: str | dict[str, Any] | None = None,
|
|
485
|
-
json: dict[str, Any] | None = None,
|
|
486
|
-
data: Any | None = None,
|
|
487
|
-
retry_on_proxy_error: bool = False,
|
|
488
|
-
max_retries: int = 5,
|
|
489
|
-
) -> str:
|
|
490
|
-
"""
|
|
491
|
-
Выполняет HTTP запрос и возвращает ответ как строку.
|
|
492
|
-
|
|
493
|
-
Используется для получения HTML, XML или других текстовых форматов.
|
|
494
|
-
|
|
495
|
-
Args:
|
|
496
|
-
method: HTTP метод
|
|
497
|
-
uri: URI endpoint (будет добавлен к base_url)
|
|
498
|
-
headers: Дополнительные заголовки
|
|
499
|
-
params: Query параметры
|
|
500
|
-
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
501
|
-
data: Данные тела запроса (альтернатива json)
|
|
502
|
-
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
503
|
-
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
504
|
-
|
|
505
|
-
Returns:
|
|
506
|
-
Текст ответа как строка
|
|
507
|
-
|
|
508
|
-
Raises:
|
|
509
|
-
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
510
|
-
ClientTimeoutException: При таймауте соединения
|
|
511
|
-
ClientProxyException: При ошибке прокси
|
|
512
|
-
ClientConnectionException: При ошибке соединения
|
|
513
|
-
ClientDNSException: При ошибке DNS
|
|
514
|
-
"""
|
|
515
|
-
return await self._make_request(
|
|
516
|
-
method=method,
|
|
517
|
-
uri=uri,
|
|
518
|
-
headers=headers,
|
|
519
|
-
params=params,
|
|
520
|
-
json=json,
|
|
521
|
-
data=data,
|
|
522
|
-
retry_on_proxy_error=retry_on_proxy_error,
|
|
523
|
-
max_retries=max_retries,
|
|
524
|
-
)
|
|
525
|
-
|
|
526
|
-
async def request_json(
|
|
527
|
-
self,
|
|
528
|
-
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
529
|
-
uri: str,
|
|
530
|
-
headers: dict[str, Any] | None = None,
|
|
531
|
-
params: str | dict[str, Any] | None = None,
|
|
532
|
-
json: dict[str, Any] | None = None,
|
|
533
|
-
data: Any | None = None,
|
|
534
|
-
retry_on_proxy_error: bool = False,
|
|
535
|
-
max_retries: int = 5,
|
|
536
|
-
) -> dict[str, Any] | list[Any]:
|
|
537
|
-
"""
|
|
538
|
-
Выполняет HTTP запрос и возвращает ответ как распарсенный JSON.
|
|
539
|
-
|
|
540
|
-
Args:
|
|
541
|
-
method: HTTP метод
|
|
542
|
-
uri: URI endpoint (будет добавлен к base_url)
|
|
543
|
-
headers: Дополнительные заголовки
|
|
544
|
-
params: Query параметры
|
|
545
|
-
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
546
|
-
data: Данные тела запроса (альтернатива json)
|
|
547
|
-
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
548
|
-
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
549
|
-
|
|
550
|
-
Returns:
|
|
551
|
-
Распарсенный JSON (dict или list)
|
|
552
|
-
|
|
553
|
-
Raises:
|
|
554
|
-
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
555
|
-
ClientTimeoutException: При таймауте соединения
|
|
556
|
-
ClientProxyException: При ошибке прокси
|
|
557
|
-
ClientConnectionException: При ошибке соединения
|
|
558
|
-
ClientDNSException: При ошибке DNS
|
|
559
|
-
json.JSONDecodeError: Если ответ не является валидным JSON
|
|
560
|
-
"""
|
|
561
|
-
response_text = await self._make_request(
|
|
562
|
-
method=method,
|
|
563
|
-
uri=uri,
|
|
564
|
-
headers=headers,
|
|
565
|
-
params=params,
|
|
566
|
-
json=json,
|
|
567
|
-
data=data,
|
|
568
|
-
retry_on_proxy_error=retry_on_proxy_error,
|
|
569
|
-
max_retries=max_retries,
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
try:
|
|
573
|
-
return json_lib.loads(response_text)
|
|
574
|
-
except json_lib.JSONDecodeError as e:
|
|
575
|
-
url = self._build_url(uri, params)
|
|
576
|
-
self._logger.error(
|
|
577
|
-
"Не удалось распарсить ответ как JSON",
|
|
578
|
-
extra={
|
|
579
|
-
"method": method,
|
|
580
|
-
"url": url,
|
|
581
|
-
"response": response_text,
|
|
582
|
-
"error": str(e),
|
|
583
|
-
},
|
|
584
|
-
)
|
|
585
|
-
raise
|
|
1
|
+
import asyncio
|
|
2
|
+
import json as json_lib
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, Literal, TypeVar
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from aiohttp import (
|
|
11
|
+
ClientConnectionError,
|
|
12
|
+
ClientConnectorDNSError,
|
|
13
|
+
ClientHttpProxyError,
|
|
14
|
+
ClientProxyConnectionError,
|
|
15
|
+
ServerTimeoutError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from tp_common.base_client.exceptions import (
|
|
19
|
+
BaseClientException,
|
|
20
|
+
ClientConnectionException,
|
|
21
|
+
ClientDNSException,
|
|
22
|
+
ClientProxyException,
|
|
23
|
+
ClientResponseErrorException,
|
|
24
|
+
ClientTimeoutException,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T", bound="BaseClient")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseClient:
|
|
31
|
+
"""
|
|
32
|
+
Базовый HTTP клиент для работы с внешними API.
|
|
33
|
+
|
|
34
|
+
Поддерживает:
|
|
35
|
+
- Переиспользование сессии
|
|
36
|
+
- Прокси
|
|
37
|
+
- Cookies
|
|
38
|
+
- Имитацию браузера
|
|
39
|
+
- Логирование (с автоматическим созданием логгера при необходимости)
|
|
40
|
+
- Переопределение базового URL для тестирования
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
cookies: Строка с cookies в формате "name=value; name2=value2"
|
|
44
|
+
proxy: URL прокси-сервера
|
|
45
|
+
logger: Внешний логгер (если не передан, создается автоматически)
|
|
46
|
+
base_url: Базовый URL для запросов (обязательный параметр)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
DEFAULT_TIMEOUT = 30.0 # секунды
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
base_url: str,
|
|
54
|
+
cookies: str | None = None,
|
|
55
|
+
proxy: str | None = None,
|
|
56
|
+
logger: logging.Logger | None = None,
|
|
57
|
+
):
|
|
58
|
+
self._raw_cookies = cookies
|
|
59
|
+
self._proxy: str | None = proxy
|
|
60
|
+
self._session: aiohttp.ClientSession | None = None
|
|
61
|
+
self._logger = logger or self._create_logger()
|
|
62
|
+
self._base_url = base_url
|
|
63
|
+
|
|
64
|
+
def _create_logger(self) -> logging.Logger:
|
|
65
|
+
"""Создает логгер на основе имени класса, если не передан внешний."""
|
|
66
|
+
class_name = self.__class__.__name__
|
|
67
|
+
module_name = self.__class__.__module__
|
|
68
|
+
logger_name = f"{module_name}.{class_name}"
|
|
69
|
+
return logging.getLogger(logger_name)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def logger(self) -> logging.Logger:
|
|
73
|
+
"""Возвращает логгер для использования в наследниках."""
|
|
74
|
+
return self._logger
|
|
75
|
+
|
|
76
|
+
@logger.setter
|
|
77
|
+
def logger(self, logger: logging.Logger) -> None:
|
|
78
|
+
self._logger = logger
|
|
79
|
+
|
|
80
|
+
async def __aenter__(self: T) -> T:
|
|
81
|
+
"""Инициализация сессии при входе в контекстный менеджер"""
|
|
82
|
+
_ = await self._get_session()
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
86
|
+
"""Закрытие сессии при выходе из контекстного менеджера"""
|
|
87
|
+
await self.close()
|
|
88
|
+
|
|
89
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
90
|
+
"""Получает или создает переиспользуемую сессию"""
|
|
91
|
+
if self._session is None or self._session.closed:
|
|
92
|
+
timeout = aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
|
|
93
|
+
connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
|
|
94
|
+
|
|
95
|
+
self._session = aiohttp.ClientSession(
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
connector=connector,
|
|
98
|
+
cookies=self._prepare_cookies_dict(),
|
|
99
|
+
)
|
|
100
|
+
return self._session
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
"""Закрывает HTTP сессию"""
|
|
104
|
+
if self._session and not self._session.closed:
|
|
105
|
+
await self._session.close()
|
|
106
|
+
self._session = None
|
|
107
|
+
|
|
108
|
+
def _get_default_headers(self) -> dict[str, str]:
|
|
109
|
+
"""Возвращает заголовки по умолчанию для имитации браузера"""
|
|
110
|
+
return {
|
|
111
|
+
"accept": "*/*",
|
|
112
|
+
"accept-language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
113
|
+
"sec-ch-ua": '"Chromium";v="122"',
|
|
114
|
+
"sec-ch-ua-mobile": "?0",
|
|
115
|
+
"sec-fetch-dest": "empty",
|
|
116
|
+
"sec-fetch-mode": "cors",
|
|
117
|
+
"sec-fetch-site": "same-origin",
|
|
118
|
+
"referer": f"{self._base_url}/",
|
|
119
|
+
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def cookies(self) -> str | None:
|
|
124
|
+
"""Возвращает текущие cookies"""
|
|
125
|
+
return self._raw_cookies
|
|
126
|
+
|
|
127
|
+
@cookies.setter
|
|
128
|
+
def cookies(self, cookies: str) -> None:
|
|
129
|
+
"""Устанавливает новые cookies (требует пересоздания сессии)"""
|
|
130
|
+
self._raw_cookies = cookies
|
|
131
|
+
# Сразу обнуляем _session, чтобы следующий _get_session() создал новую сессию
|
|
132
|
+
# с новыми cookies; старую сессию закрываем в фоне (иначе гонка: запрос
|
|
133
|
+
# может получить уже закрываемую сессию → "Connector is closed")
|
|
134
|
+
old_session = self._session
|
|
135
|
+
self._session = None
|
|
136
|
+
if old_session and not old_session.closed:
|
|
137
|
+
|
|
138
|
+
async def _close_old() -> None:
|
|
139
|
+
await old_session.close()
|
|
140
|
+
|
|
141
|
+
asyncio.create_task(_close_old())
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def proxy(self) -> str | None:
|
|
145
|
+
"""Возвращает текущий прокси"""
|
|
146
|
+
return self._proxy
|
|
147
|
+
|
|
148
|
+
@proxy.setter
|
|
149
|
+
def proxy(self, proxy: str) -> None:
|
|
150
|
+
"""Устанавливает новый прокси"""
|
|
151
|
+
self._proxy = proxy
|
|
152
|
+
|
|
153
|
+
def _prepare_cookies_dict(self) -> dict[str, str] | None:
|
|
154
|
+
"""Подготавливает словарь cookies из строки для aiohttp"""
|
|
155
|
+
if not self._raw_cookies:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
cookies_dict: dict[str, str] = {}
|
|
159
|
+
|
|
160
|
+
# Парсим строку cookies в формате "name=value; name2=value2"
|
|
161
|
+
for cookie_str in self._raw_cookies.split(";"):
|
|
162
|
+
cookie_str = cookie_str.strip()
|
|
163
|
+
if not cookie_str:
|
|
164
|
+
continue
|
|
165
|
+
if "=" in cookie_str:
|
|
166
|
+
name, value = cookie_str.split("=", 1)
|
|
167
|
+
cookies_dict[name.strip()] = value.strip()
|
|
168
|
+
|
|
169
|
+
return cookies_dict if cookies_dict else None
|
|
170
|
+
|
|
171
|
+
def extract_cookies_from_session(self) -> str | None:
|
|
172
|
+
"""
|
|
173
|
+
Извлекает cookies из текущей сессии в строку формата 'name=value; name2=value2'.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Строка с кукисами в формате 'name=value; name2=value2' или None
|
|
177
|
+
"""
|
|
178
|
+
if not self._session or self._session.closed:
|
|
179
|
+
return self._raw_cookies
|
|
180
|
+
|
|
181
|
+
cookies_list: list[str] = []
|
|
182
|
+
for cookie in self._session.cookie_jar:
|
|
183
|
+
cookies_list.append(f"{cookie.key}={cookie.value}")
|
|
184
|
+
|
|
185
|
+
if not cookies_list:
|
|
186
|
+
return self._raw_cookies
|
|
187
|
+
|
|
188
|
+
return "; ".join(cookies_list)
|
|
189
|
+
|
|
190
|
+
def _build_url(self, uri: str, params: str | dict[str, Any] | None = None) -> str:
|
|
191
|
+
"""Строит полный URL из базового адреса, URI и параметров"""
|
|
192
|
+
url = f"{self._base_url}/{uri.lstrip('/')}"
|
|
193
|
+
|
|
194
|
+
if params and isinstance(params, dict):
|
|
195
|
+
# Фильтруем None значения и конвертируем все значения в строки для urlencode
|
|
196
|
+
filtered_params: dict[str, str] = {}
|
|
197
|
+
for key, val in params.items():
|
|
198
|
+
if val is not None:
|
|
199
|
+
# Для datetime объектов используем ISO формат с Z и миллисекундами
|
|
200
|
+
if isinstance(val, datetime):
|
|
201
|
+
# Формат: 2026-01-20T00:00:00.000Z
|
|
202
|
+
# Если datetime имеет timezone, конвертируем в UTC, иначе используем как есть
|
|
203
|
+
if val.tzinfo is not None:
|
|
204
|
+
# Конвертируем в UTC
|
|
205
|
+
val_utc = val.astimezone(UTC)
|
|
206
|
+
filtered_params[key] = (
|
|
207
|
+
val_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
# Naive datetime - используем как есть и добавляем Z
|
|
211
|
+
filtered_params[key] = (
|
|
212
|
+
val.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
filtered_params[key] = str(val)
|
|
216
|
+
if filtered_params:
|
|
217
|
+
# Используем urlencode для правильного кодирования параметров URL
|
|
218
|
+
query_string = urlencode(filtered_params, doseq=False)
|
|
219
|
+
url += "?" + query_string
|
|
220
|
+
elif params and isinstance(params, str):
|
|
221
|
+
url += f"?{params}"
|
|
222
|
+
|
|
223
|
+
return url
|
|
224
|
+
|
|
225
|
+
def _merge_headers(self, custom_headers: dict[str, Any] | None) -> dict[str, str]:
|
|
226
|
+
"""Объединяет заголовки по умолчанию с пользовательскими"""
|
|
227
|
+
headers = self._get_default_headers()
|
|
228
|
+
if custom_headers:
|
|
229
|
+
headers.update(custom_headers)
|
|
230
|
+
return headers
|
|
231
|
+
|
|
232
|
+
def _prepare_payload(
|
|
233
|
+
self, json: dict[str, Any] | None, data: Any | None
|
|
234
|
+
) -> dict[str, Any] | list[Any] | str | None:
|
|
235
|
+
"""Подготавливает payload для логирования"""
|
|
236
|
+
if json is not None:
|
|
237
|
+
return json
|
|
238
|
+
if data is not None:
|
|
239
|
+
if isinstance(data, str):
|
|
240
|
+
try:
|
|
241
|
+
return json_lib.loads(data)
|
|
242
|
+
except (json_lib.JSONDecodeError, TypeError):
|
|
243
|
+
return None
|
|
244
|
+
elif isinstance(data, (dict, list)):
|
|
245
|
+
return data
|
|
246
|
+
return None
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def _prepare_response(self, response_text: str) -> dict[str, Any] | list[Any] | str:
|
|
250
|
+
"""Подготавливает response для логирования: пытается распарсить JSON, иначе возвращает текст"""
|
|
251
|
+
if not response_text:
|
|
252
|
+
return response_text
|
|
253
|
+
try:
|
|
254
|
+
return json_lib.loads(response_text)
|
|
255
|
+
except (json_lib.JSONDecodeError, TypeError):
|
|
256
|
+
return response_text
|
|
257
|
+
|
|
258
|
+
def _decode_response_body(
|
|
259
|
+
self, body_bytes: bytes, response: aiohttp.ClientResponse
|
|
260
|
+
) -> str:
|
|
261
|
+
"""
|
|
262
|
+
Декодирует тело ответа в строку с учётом кодировки из заголовков
|
|
263
|
+
и резервных кодировок при ошибке UTF-8 (например, cp1251 для российских API).
|
|
264
|
+
"""
|
|
265
|
+
encodings_to_try: list[str | tuple[str, str]] = []
|
|
266
|
+
if response.charset:
|
|
267
|
+
encodings_to_try.append(response.charset)
|
|
268
|
+
encodings_to_try.append("utf-8")
|
|
269
|
+
encodings_to_try.append("cp1251") # Windows-1251, типично для российских API
|
|
270
|
+
encodings_to_try.append(("iso-8859-1", "replace"))
|
|
271
|
+
for entry in encodings_to_try:
|
|
272
|
+
if isinstance(entry, tuple):
|
|
273
|
+
encoding, errors = entry
|
|
274
|
+
else:
|
|
275
|
+
encoding, errors = entry, "strict"
|
|
276
|
+
try:
|
|
277
|
+
return body_bytes.decode(encoding, errors=errors)
|
|
278
|
+
except (UnicodeDecodeError, LookupError):
|
|
279
|
+
continue
|
|
280
|
+
return body_bytes.decode("iso-8859-1", errors="replace")
|
|
281
|
+
|
|
282
|
+
async def _make_request(
|
|
283
|
+
self,
|
|
284
|
+
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
285
|
+
uri: str,
|
|
286
|
+
headers: dict[str, Any] | None = None,
|
|
287
|
+
params: str | dict[str, Any] | None = None,
|
|
288
|
+
json: dict[str, Any] | None = None,
|
|
289
|
+
data: Any | None = None,
|
|
290
|
+
retry_on_proxy_error: bool = False,
|
|
291
|
+
max_retries: int = 5,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""
|
|
294
|
+
Базовый метод для выполнения HTTP запросов. Возвращает только строку.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
method: HTTP метод
|
|
298
|
+
uri: URI endpoint (будет добавлен к base_url)
|
|
299
|
+
headers: Дополнительные заголовки
|
|
300
|
+
params: Query параметры
|
|
301
|
+
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
302
|
+
data: Данные тела запроса (альтернатива json)
|
|
303
|
+
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
304
|
+
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Текст ответа как строка
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
311
|
+
ClientTimeoutException: При таймауте соединения
|
|
312
|
+
ClientProxyException: При ошибке прокси
|
|
313
|
+
ClientConnectionException: При ошибке соединения
|
|
314
|
+
ClientDNSException: При ошибке DNS
|
|
315
|
+
"""
|
|
316
|
+
url = self._build_url(uri, params)
|
|
317
|
+
merged_headers = self._merge_headers(headers)
|
|
318
|
+
session = await self._get_session()
|
|
319
|
+
|
|
320
|
+
# Определяем kwargs для запроса
|
|
321
|
+
request_kwargs: dict[str, Any] = {
|
|
322
|
+
"url": url,
|
|
323
|
+
"headers": merged_headers,
|
|
324
|
+
"proxy": self._proxy,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if json is not None:
|
|
328
|
+
request_kwargs["json"] = json
|
|
329
|
+
elif data is not None:
|
|
330
|
+
request_kwargs["data"] = data
|
|
331
|
+
|
|
332
|
+
payload = self._prepare_payload(json, data)
|
|
333
|
+
amount_failed = 0
|
|
334
|
+
|
|
335
|
+
while True:
|
|
336
|
+
response_body_text = ""
|
|
337
|
+
start_time = time.perf_counter()
|
|
338
|
+
try:
|
|
339
|
+
async with session.request(method, **request_kwargs) as response:
|
|
340
|
+
body_bytes = await response.read()
|
|
341
|
+
response_body_text = self._decode_response_body(
|
|
342
|
+
body_bytes, response
|
|
343
|
+
)
|
|
344
|
+
end_time = time.perf_counter()
|
|
345
|
+
duration_ms = int((end_time - start_time) * 1000)
|
|
346
|
+
|
|
347
|
+
response_data = self._prepare_response(response_body_text)
|
|
348
|
+
|
|
349
|
+
self._logger.info(
|
|
350
|
+
"HTTP запрос выполнен",
|
|
351
|
+
extra={
|
|
352
|
+
"url": url,
|
|
353
|
+
"method": method,
|
|
354
|
+
"status_code": response.status,
|
|
355
|
+
"payload": payload,
|
|
356
|
+
"response": response_data,
|
|
357
|
+
"duration": duration_ms,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if response.status >= 400:
|
|
362
|
+
raise ClientResponseErrorException(
|
|
363
|
+
f"HTTP {response.status} error: {response.reason}",
|
|
364
|
+
url=url,
|
|
365
|
+
status_code=response.status,
|
|
366
|
+
response_body=response_body_text,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return response_body_text
|
|
370
|
+
|
|
371
|
+
except (ClientHttpProxyError, ClientProxyConnectionError) as e:
|
|
372
|
+
if not retry_on_proxy_error:
|
|
373
|
+
self._logger.error(
|
|
374
|
+
"Ошибка прокси при HTTP запросе",
|
|
375
|
+
extra={
|
|
376
|
+
"method": method,
|
|
377
|
+
"url": url,
|
|
378
|
+
"proxy": self._proxy,
|
|
379
|
+
"error": str(e),
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
raise ClientProxyException(
|
|
383
|
+
f"Proxy error: {str(e)}",
|
|
384
|
+
url=url,
|
|
385
|
+
proxy=self._proxy,
|
|
386
|
+
) from e
|
|
387
|
+
|
|
388
|
+
amount_failed += 1
|
|
389
|
+
if amount_failed >= max_retries:
|
|
390
|
+
self._logger.error(
|
|
391
|
+
"Ошибка прокси после исчерпания попыток",
|
|
392
|
+
extra={
|
|
393
|
+
"method": method,
|
|
394
|
+
"url": url,
|
|
395
|
+
"proxy": self._proxy,
|
|
396
|
+
"retries": amount_failed,
|
|
397
|
+
"error": str(e),
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
raise ClientProxyException(
|
|
401
|
+
f"Proxy error after {max_retries} retries: {str(e)}",
|
|
402
|
+
url=url,
|
|
403
|
+
proxy=self._proxy,
|
|
404
|
+
) from e
|
|
405
|
+
|
|
406
|
+
self._logger.warning(
|
|
407
|
+
"Повтор запроса после ошибки прокси",
|
|
408
|
+
extra={
|
|
409
|
+
"method": method,
|
|
410
|
+
"url": url,
|
|
411
|
+
"proxy": self._proxy,
|
|
412
|
+
"retry": amount_failed,
|
|
413
|
+
"max_retries": max_retries,
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
except (TimeoutError, ServerTimeoutError) as e:
|
|
419
|
+
# self._logger.error(
|
|
420
|
+
# "Таймаут HTTP запроса",
|
|
421
|
+
# extra={
|
|
422
|
+
# "method": method,
|
|
423
|
+
# "url": url,
|
|
424
|
+
# "timeout": self.DEFAULT_TIMEOUT,
|
|
425
|
+
# "error": str(e),
|
|
426
|
+
# },
|
|
427
|
+
# )
|
|
428
|
+
raise ClientTimeoutException(
|
|
429
|
+
f"Request timeout after {self.DEFAULT_TIMEOUT}s: {str(e)}",
|
|
430
|
+
url=url,
|
|
431
|
+
) from e
|
|
432
|
+
|
|
433
|
+
except ClientConnectorDNSError as e:
|
|
434
|
+
self._logger.error(
|
|
435
|
+
"Ошибка DNS при HTTP запросе",
|
|
436
|
+
extra={
|
|
437
|
+
"method": method,
|
|
438
|
+
"url": url,
|
|
439
|
+
"error": str(e),
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
raise ClientDNSException(
|
|
443
|
+
f"DNS resolution failed: {str(e)}",
|
|
444
|
+
url=url,
|
|
445
|
+
) from e
|
|
446
|
+
|
|
447
|
+
except ClientConnectionError as e:
|
|
448
|
+
self._logger.error(
|
|
449
|
+
"Ошибка соединения при HTTP запросе",
|
|
450
|
+
extra={
|
|
451
|
+
"method": method,
|
|
452
|
+
"url": url,
|
|
453
|
+
"error": str(e),
|
|
454
|
+
},
|
|
455
|
+
)
|
|
456
|
+
raise ClientConnectionException(
|
|
457
|
+
f"Connection error: {str(e)}",
|
|
458
|
+
url=url,
|
|
459
|
+
) from e
|
|
460
|
+
except ClientResponseErrorException as e:
|
|
461
|
+
raise e
|
|
462
|
+
except Exception as e:
|
|
463
|
+
# Неожиданные ошибки
|
|
464
|
+
self._logger.error(
|
|
465
|
+
"Неожиданная ошибка HTTP запроса",
|
|
466
|
+
extra={
|
|
467
|
+
"method": method,
|
|
468
|
+
"url": url,
|
|
469
|
+
"error_type": type(e).__name__,
|
|
470
|
+
"error": str(e),
|
|
471
|
+
"response": response_body_text or "",
|
|
472
|
+
},
|
|
473
|
+
)
|
|
474
|
+
raise BaseClientException(
|
|
475
|
+
f"Неожиданная HTTP-ошибка: {str(e)}",
|
|
476
|
+
url=url,
|
|
477
|
+
) from e
|
|
478
|
+
|
|
479
|
+
async def request_text(
|
|
480
|
+
self,
|
|
481
|
+
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
482
|
+
uri: str,
|
|
483
|
+
headers: dict[str, Any] | None = None,
|
|
484
|
+
params: str | dict[str, Any] | None = None,
|
|
485
|
+
json: dict[str, Any] | None = None,
|
|
486
|
+
data: Any | None = None,
|
|
487
|
+
retry_on_proxy_error: bool = False,
|
|
488
|
+
max_retries: int = 5,
|
|
489
|
+
) -> str:
|
|
490
|
+
"""
|
|
491
|
+
Выполняет HTTP запрос и возвращает ответ как строку.
|
|
492
|
+
|
|
493
|
+
Используется для получения HTML, XML или других текстовых форматов.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
method: HTTP метод
|
|
497
|
+
uri: URI endpoint (будет добавлен к base_url)
|
|
498
|
+
headers: Дополнительные заголовки
|
|
499
|
+
params: Query параметры
|
|
500
|
+
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
501
|
+
data: Данные тела запроса (альтернатива json)
|
|
502
|
+
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
503
|
+
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
Текст ответа как строка
|
|
507
|
+
|
|
508
|
+
Raises:
|
|
509
|
+
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
510
|
+
ClientTimeoutException: При таймауте соединения
|
|
511
|
+
ClientProxyException: При ошибке прокси
|
|
512
|
+
ClientConnectionException: При ошибке соединения
|
|
513
|
+
ClientDNSException: При ошибке DNS
|
|
514
|
+
"""
|
|
515
|
+
return await self._make_request(
|
|
516
|
+
method=method,
|
|
517
|
+
uri=uri,
|
|
518
|
+
headers=headers,
|
|
519
|
+
params=params,
|
|
520
|
+
json=json,
|
|
521
|
+
data=data,
|
|
522
|
+
retry_on_proxy_error=retry_on_proxy_error,
|
|
523
|
+
max_retries=max_retries,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def request_json(
|
|
527
|
+
self,
|
|
528
|
+
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
529
|
+
uri: str,
|
|
530
|
+
headers: dict[str, Any] | None = None,
|
|
531
|
+
params: str | dict[str, Any] | None = None,
|
|
532
|
+
json: dict[str, Any] | None = None,
|
|
533
|
+
data: Any | None = None,
|
|
534
|
+
retry_on_proxy_error: bool = False,
|
|
535
|
+
max_retries: int = 5,
|
|
536
|
+
) -> dict[str, Any] | list[Any]:
|
|
537
|
+
"""
|
|
538
|
+
Выполняет HTTP запрос и возвращает ответ как распарсенный JSON.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
method: HTTP метод
|
|
542
|
+
uri: URI endpoint (будет добавлен к base_url)
|
|
543
|
+
headers: Дополнительные заголовки
|
|
544
|
+
params: Query параметры
|
|
545
|
+
json: JSON тело запроса (для POST/PUT/PATCH)
|
|
546
|
+
data: Данные тела запроса (альтернатива json)
|
|
547
|
+
retry_on_proxy_error: Повторять ли запрос при ошибках прокси
|
|
548
|
+
max_retries: Максимальное количество попыток при retry_on_proxy_error
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Распарсенный JSON (dict или list)
|
|
552
|
+
|
|
553
|
+
Raises:
|
|
554
|
+
ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
|
|
555
|
+
ClientTimeoutException: При таймауте соединения
|
|
556
|
+
ClientProxyException: При ошибке прокси
|
|
557
|
+
ClientConnectionException: При ошибке соединения
|
|
558
|
+
ClientDNSException: При ошибке DNS
|
|
559
|
+
json.JSONDecodeError: Если ответ не является валидным JSON
|
|
560
|
+
"""
|
|
561
|
+
response_text = await self._make_request(
|
|
562
|
+
method=method,
|
|
563
|
+
uri=uri,
|
|
564
|
+
headers=headers,
|
|
565
|
+
params=params,
|
|
566
|
+
json=json,
|
|
567
|
+
data=data,
|
|
568
|
+
retry_on_proxy_error=retry_on_proxy_error,
|
|
569
|
+
max_retries=max_retries,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
return json_lib.loads(response_text)
|
|
574
|
+
except json_lib.JSONDecodeError as e:
|
|
575
|
+
url = self._build_url(uri, params)
|
|
576
|
+
self._logger.error(
|
|
577
|
+
"Не удалось распарсить ответ как JSON",
|
|
578
|
+
extra={
|
|
579
|
+
"method": method,
|
|
580
|
+
"url": url,
|
|
581
|
+
"response": response_text,
|
|
582
|
+
"error": str(e),
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
raise
|