ozonapi-async 0.1.0__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.
- ozonapi/__init__.py +9 -0
- ozonapi/seller/__init__.py +29 -0
- ozonapi/seller/common/__init__.py +0 -0
- ozonapi/seller/common/enumerations/__init__.py +0 -0
- ozonapi/seller/common/enumerations/delivery.py +16 -0
- ozonapi/seller/common/enumerations/localization.py +39 -0
- ozonapi/seller/common/enumerations/postings.py +205 -0
- ozonapi/seller/common/enumerations/products.py +225 -0
- ozonapi/seller/common/enumerations/requests.py +26 -0
- ozonapi/seller/common/enumerations/warehouses.py +54 -0
- ozonapi/seller/core/__init__.py +14 -0
- ozonapi/seller/core/config.py +88 -0
- ozonapi/seller/core/core.py +404 -0
- ozonapi/seller/core/exceptions.py +32 -0
- ozonapi/seller/core/method_rate_limiter.py +199 -0
- ozonapi/seller/core/rate_limiter.py +174 -0
- ozonapi/seller/core/sessions.py +75 -0
- ozonapi/seller/methods/__init__.py +15 -0
- ozonapi/seller/methods/attributes_and_characteristics.py +177 -0
- ozonapi/seller/methods/barcodes.py +84 -0
- ozonapi/seller/methods/fbs.py +69 -0
- ozonapi/seller/methods/prices_and_stocks.py +147 -0
- ozonapi/seller/methods/products.py +673 -0
- ozonapi/seller/methods/warehouses.py +80 -0
- ozonapi/seller/schemas/__init__.py +0 -0
- ozonapi/seller/schemas/attributes_and_characteristics/__init__.py +21 -0
- ozonapi/seller/schemas/attributes_and_characteristics/base.py +52 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute.py +108 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values.py +38 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_attribute_values_search.py +36 -0
- ozonapi/seller/schemas/attributes_and_characteristics/v1__description_category_tree.py +61 -0
- ozonapi/seller/schemas/barcodes/__init__.py +12 -0
- ozonapi/seller/schemas/barcodes/v1__barcode_add.py +66 -0
- ozonapi/seller/schemas/barcodes/v1__barcode_generate.py +46 -0
- ozonapi/seller/schemas/base.py +94 -0
- ozonapi/seller/schemas/fbs/__init__.py +9 -0
- ozonapi/seller/schemas/fbs/v3__posting_fbs_unfulfilled_list.py +764 -0
- ozonapi/seller/schemas/prices_and_stocks/__init__.py +16 -0
- ozonapi/seller/schemas/prices_and_stocks/base.py +26 -0
- ozonapi/seller/schemas/prices_and_stocks/v1__product_info_stocks_by_warehouse_fbs.py +55 -0
- ozonapi/seller/schemas/prices_and_stocks/v4__product_info_stocks.py +113 -0
- ozonapi/seller/schemas/prices_and_stocks/v5__product_info_prices.py +292 -0
- ozonapi/seller/schemas/products/__init__.py +51 -0
- ozonapi/seller/schemas/products/base.py +162 -0
- ozonapi/seller/schemas/products/v1__product_archive.py +20 -0
- ozonapi/seller/schemas/products/v1__product_attributes_update.py +79 -0
- ozonapi/seller/schemas/products/v1__product_import_by_sku.py +71 -0
- ozonapi/seller/schemas/products/v1__product_import_info.py +103 -0
- ozonapi/seller/schemas/products/v1__product_info_subscription.py +39 -0
- ozonapi/seller/schemas/products/v1__product_rating_by_sku.py +116 -0
- ozonapi/seller/schemas/products/v1__product_related_sku_get.py +81 -0
- ozonapi/seller/schemas/products/v1__product_unarchive.py +20 -0
- ozonapi/seller/schemas/products/v1__product_update_offer_id.py +52 -0
- ozonapi/seller/schemas/products/v2__product_pictures_info.py +73 -0
- ozonapi/seller/schemas/products/v2__products_delete.py +57 -0
- ozonapi/seller/schemas/products/v3__product_import.py +142 -0
- ozonapi/seller/schemas/products/v3__product_info_list.py +482 -0
- ozonapi/seller/schemas/products/v3__product_list.py +107 -0
- ozonapi/seller/schemas/products/v4__product_info_attributes.py +137 -0
- ozonapi/seller/schemas/warehouses/__init__.py +11 -0
- ozonapi/seller/schemas/warehouses/v1__delivery_method_list.py +95 -0
- ozonapi/seller/schemas/warehouses/v1__warehouse_list.py +109 -0
- ozonapi_async-0.1.0.dist-info/METADATA +648 -0
- ozonapi_async-0.1.0.dist-info/RECORD +67 -0
- ozonapi_async-0.1.0.dist-info/WHEEL +5 -0
- ozonapi_async-0.1.0.dist-info/licenses/LICENSE +21 -0
- ozonapi_async-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, field_validator, ConfigDict
|
|
4
|
+
from pydantic_settings import BaseSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class APIConfig(BaseSettings):
|
|
8
|
+
"""Конфигурация API клиента."""
|
|
9
|
+
|
|
10
|
+
client_id: Optional[str] = Field(
|
|
11
|
+
default=None,
|
|
12
|
+
description="Идентификатор клиента Ozon",
|
|
13
|
+
)
|
|
14
|
+
api_key: Optional[str] = Field(
|
|
15
|
+
default=None,
|
|
16
|
+
description="Авторизационный ключ Ozon Seller API",
|
|
17
|
+
)
|
|
18
|
+
log_level: Optional[str] = Field(
|
|
19
|
+
default="DEBUG",
|
|
20
|
+
description="Уровень логирования."
|
|
21
|
+
)
|
|
22
|
+
base_url: str = Field(
|
|
23
|
+
default="https://api-seller.ozon.ru",
|
|
24
|
+
description="Базовый URL API Ozon"
|
|
25
|
+
)
|
|
26
|
+
max_requests_per_second: int = Field(
|
|
27
|
+
default=50,
|
|
28
|
+
ge=1,
|
|
29
|
+
le=50,
|
|
30
|
+
description="Максимальное количество запросов в секунду (50 по документации Ozon)"
|
|
31
|
+
)
|
|
32
|
+
cleanup_interval: float = Field(
|
|
33
|
+
default=300.0,
|
|
34
|
+
gt=0,
|
|
35
|
+
description="Интервал очистки неиспользуемых ресурсов в секундах"
|
|
36
|
+
)
|
|
37
|
+
min_instance_ttl: float = Field(
|
|
38
|
+
default=300.0,
|
|
39
|
+
gt=0,
|
|
40
|
+
description="Минимальное время жизни ограничителей запросов для ClientID в секундах"
|
|
41
|
+
)
|
|
42
|
+
connector_limit: int = Field(
|
|
43
|
+
default=100,
|
|
44
|
+
ge=1,
|
|
45
|
+
description="Лимит одновременных соединений для клиента"
|
|
46
|
+
)
|
|
47
|
+
request_timeout: float = Field(
|
|
48
|
+
default=30.0,
|
|
49
|
+
gt=0,
|
|
50
|
+
description="Таймаут запросов в секундах"
|
|
51
|
+
)
|
|
52
|
+
max_retries: int = Field(
|
|
53
|
+
default=3,
|
|
54
|
+
ge=0,
|
|
55
|
+
le=10,
|
|
56
|
+
description="Максимальное количество повторных попыток для неудачных запросов"
|
|
57
|
+
)
|
|
58
|
+
retry_min_wait: float = Field(
|
|
59
|
+
default=4.0,
|
|
60
|
+
gt=0,
|
|
61
|
+
description="Минимальная задержка между повторами неудачных запросов в секундах"
|
|
62
|
+
)
|
|
63
|
+
retry_max_wait: float = Field(
|
|
64
|
+
default=10.0,
|
|
65
|
+
gt=0,
|
|
66
|
+
description="Максимальная задержка между повторами неудачных запросов в секундах"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@field_validator("base_url")
|
|
70
|
+
def validate_base_url(cls, v: str) -> str:
|
|
71
|
+
"""Валидация базового URL."""
|
|
72
|
+
if not v.startswith(("http://", "https://")):
|
|
73
|
+
raise ValueError("URL должен начинаться с http:// или https://")
|
|
74
|
+
return v.rstrip("/")
|
|
75
|
+
|
|
76
|
+
@field_validator("retry_max_wait")
|
|
77
|
+
def validate_retry_times(cls, v: float, info) -> float:
|
|
78
|
+
"""Валидация времени повторов."""
|
|
79
|
+
if "retry_min_wait" in info.data and v < info.data["retry_min_wait"]:
|
|
80
|
+
raise ValueError("retry_max_wait должен быть больше или равен retry_min_wait")
|
|
81
|
+
return v
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(
|
|
84
|
+
env_prefix='OZON_SELLER_', #type: ignore
|
|
85
|
+
case_sensitive=False, #type: ignore
|
|
86
|
+
env_file='.env', #type: ignore
|
|
87
|
+
extra='ignore',
|
|
88
|
+
)
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any, Literal, Optional, ClassVar
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from tenacity import (
|
|
9
|
+
retry,
|
|
10
|
+
retry_if_exception_type,
|
|
11
|
+
stop_after_attempt,
|
|
12
|
+
wait_exponential,
|
|
13
|
+
before_sleep_log,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .config import APIConfig
|
|
17
|
+
from .method_rate_limiter import MethodRateLimiterManager
|
|
18
|
+
from .rate_limiter import RateLimiterConfig, RateLimiterManager
|
|
19
|
+
from .sessions import SessionManager
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
APIClientError,
|
|
22
|
+
APIConflictError,
|
|
23
|
+
APIError,
|
|
24
|
+
APIForbiddenError,
|
|
25
|
+
APINotFoundError,
|
|
26
|
+
APIServerError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger.remove()
|
|
30
|
+
logger.add(
|
|
31
|
+
sys.stderr,
|
|
32
|
+
level=APIConfig().log_level,
|
|
33
|
+
enqueue=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIManager:
|
|
38
|
+
"""
|
|
39
|
+
Базовый класс для работы с API.
|
|
40
|
+
|
|
41
|
+
Предоставляет основные методы для взаимодействия с API, включая управление сессией,
|
|
42
|
+
аутентификацию и базовые HTTP-запросы.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Общие менеджеры для всех экземпляров класса
|
|
46
|
+
_rate_limiter_manager: ClassVar[Optional[RateLimiterManager]] = None
|
|
47
|
+
_session_manager: ClassVar[Optional[SessionManager]] = None
|
|
48
|
+
_method_rate_limiter_manager: ClassVar[Optional[MethodRateLimiterManager]] = None
|
|
49
|
+
_initialized: ClassVar[bool] = False
|
|
50
|
+
_instance_count: ClassVar[int] = 0
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
client_id: Optional[str] = None,
|
|
55
|
+
api_key: Optional[str] = None,
|
|
56
|
+
config: Optional[APIConfig] = None
|
|
57
|
+
) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Инициализация клиента API Ozon.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
client_id: ID клиента для доступа к API
|
|
63
|
+
api_key: Ключ API для аутентификации
|
|
64
|
+
config: Конфигурация клиента
|
|
65
|
+
"""
|
|
66
|
+
self._config = config or self.load_config()
|
|
67
|
+
self._client_id = client_id or self._config.client_id
|
|
68
|
+
self._api_key = api_key or self._config.api_key
|
|
69
|
+
self._instance_id = id(self)
|
|
70
|
+
self._registered = False
|
|
71
|
+
self._closed = False
|
|
72
|
+
|
|
73
|
+
if self._client_id is None or self._api_key is None:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Не предоставлены авторизационные данные. Проверьте указание client_id и api_key."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if APIManager._rate_limiter_manager is None:
|
|
79
|
+
APIManager._rate_limiter_manager = RateLimiterManager(
|
|
80
|
+
cleanup_interval=self._config.cleanup_interval
|
|
81
|
+
)
|
|
82
|
+
if APIManager._session_manager is None:
|
|
83
|
+
APIManager._session_manager = SessionManager(
|
|
84
|
+
timeout=self._config.request_timeout,
|
|
85
|
+
connector_limit=self._config.connector_limit
|
|
86
|
+
)
|
|
87
|
+
if APIManager._method_rate_limiter_manager is None:
|
|
88
|
+
APIManager._method_rate_limiter_manager = MethodRateLimiterManager(
|
|
89
|
+
cleanup_interval=self._config.cleanup_interval
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
APIManager._instance_count += 1
|
|
93
|
+
self._validate_credentials()
|
|
94
|
+
logger.debug(f"API-клиент инициализирован для ClientID {self._client_id}")
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def load_config(cls) -> APIConfig:
|
|
98
|
+
"""Создает конфигурацию с загрузкой из .env файла."""
|
|
99
|
+
return APIConfig()
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
async def initialize(cls) -> None:
|
|
103
|
+
"""Инициализация ресурсов."""
|
|
104
|
+
if not cls._initialized:
|
|
105
|
+
if cls._rate_limiter_manager:
|
|
106
|
+
await cls._rate_limiter_manager.start()
|
|
107
|
+
if cls._method_rate_limiter_manager:
|
|
108
|
+
await cls._method_rate_limiter_manager.start()
|
|
109
|
+
cls._initialized = True
|
|
110
|
+
logger.debug("Выполнена инициализация ресурсов API-менеджера")
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
async def shutdown(cls) -> None:
|
|
114
|
+
"""Очистка ресурсов."""
|
|
115
|
+
if cls._initialized:
|
|
116
|
+
if cls._rate_limiter_manager:
|
|
117
|
+
await cls._rate_limiter_manager.shutdown()
|
|
118
|
+
if cls._method_rate_limiter_manager:
|
|
119
|
+
await cls._method_rate_limiter_manager.shutdown()
|
|
120
|
+
if cls._session_manager:
|
|
121
|
+
await cls._session_manager.close_all()
|
|
122
|
+
cls._initialized = False
|
|
123
|
+
logger.debug("Выполнена деинициализация ресурсов API-менеджера")
|
|
124
|
+
|
|
125
|
+
def _validate_credentials(self) -> None:
|
|
126
|
+
"""Валидация учетных данных."""
|
|
127
|
+
if not self._client_id or not isinstance(self._client_id, str):
|
|
128
|
+
raise ValueError("client_id не должен быть пустой строкой")
|
|
129
|
+
if not self._api_key or not isinstance(self._api_key, str):
|
|
130
|
+
raise ValueError("api_key не должен быть пустой строкой")
|
|
131
|
+
|
|
132
|
+
if self._config.max_requests_per_second > 50:
|
|
133
|
+
logger.warning(
|
|
134
|
+
f"Максимальное кол-во запросов в секунду согласно документации Ozon - 50. "
|
|
135
|
+
f"Установлено: {self._config.max_requests_per_second}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def _ensure_registered(self) -> None:
|
|
139
|
+
"""Гарантирует регистрацию экземпляра в менеджерах."""
|
|
140
|
+
if self._closed:
|
|
141
|
+
raise RuntimeError(f"Регистрация API-клиента отменена для ClientID {self._client_id}")
|
|
142
|
+
|
|
143
|
+
if not self._registered and self._rate_limiter_manager:
|
|
144
|
+
await self._rate_limiter_manager.register_instance(
|
|
145
|
+
self._client_id, self._instance_id
|
|
146
|
+
)
|
|
147
|
+
self._registered = True
|
|
148
|
+
|
|
149
|
+
async def __aenter__(self) -> "APIManager":
|
|
150
|
+
"""Асинхронный контекстный менеджер."""
|
|
151
|
+
if self._closed:
|
|
152
|
+
raise RuntimeError(f"Невозможно использовать закрытый API-клиент для ClientID {self._client_id}")
|
|
153
|
+
|
|
154
|
+
await self.initialize()
|
|
155
|
+
await self._ensure_registered()
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
async def __aexit__(
|
|
159
|
+
self,
|
|
160
|
+
exc_type: Optional[type[BaseException]],
|
|
161
|
+
exc_val: Optional[BaseException],
|
|
162
|
+
exc_tb: Optional[TracebackType],
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Очистка ресурсов при выходе из контекста."""
|
|
165
|
+
await self.close()
|
|
166
|
+
|
|
167
|
+
async def close(self) -> None:
|
|
168
|
+
if self._closed:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
self._closed = True
|
|
172
|
+
|
|
173
|
+
if self._registered and self._rate_limiter_manager:
|
|
174
|
+
await self._rate_limiter_manager.unregister_instance(
|
|
175
|
+
self._client_id, self._instance_id
|
|
176
|
+
)
|
|
177
|
+
self._registered = False
|
|
178
|
+
|
|
179
|
+
APIManager._instance_count -= 1
|
|
180
|
+
|
|
181
|
+
if APIManager._instance_count == 0:
|
|
182
|
+
if APIManager._session_manager:
|
|
183
|
+
await APIManager._session_manager.close_all()
|
|
184
|
+
|
|
185
|
+
logger.debug(f"Работа API-клиента для ClientID {self._client_id} завершена")
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def client_id(self) -> str:
|
|
189
|
+
"""ID клиента."""
|
|
190
|
+
return self._client_id
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def config(self) -> APIConfig:
|
|
194
|
+
"""Конфигурация клиента."""
|
|
195
|
+
return self._config
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def is_closed(self) -> bool:
|
|
199
|
+
"""Проверяет закрыт ли клиент."""
|
|
200
|
+
return self._closed
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def get_instance_count(cls) -> int:
|
|
204
|
+
"""Получает количество активных экземпляров."""
|
|
205
|
+
return cls._instance_count
|
|
206
|
+
|
|
207
|
+
def _create_retry_decorator(self):
|
|
208
|
+
"""Создает декоратор повторов на основе конфигурации."""
|
|
209
|
+
|
|
210
|
+
def log_retry(retry_state):
|
|
211
|
+
logger.debug(
|
|
212
|
+
f"Попытка {retry_state.attempt_number} совершения запроса для ClientID {self._client_id}"
|
|
213
|
+
f" завершилась исключением: {retry_state.outcome.exception()}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return retry(
|
|
217
|
+
retry=retry_if_exception_type(
|
|
218
|
+
(
|
|
219
|
+
# Обрабатываемые механизмом retry ошибки
|
|
220
|
+
APIServerError,
|
|
221
|
+
asyncio.TimeoutError
|
|
222
|
+
)
|
|
223
|
+
),
|
|
224
|
+
stop=stop_after_attempt(self._config.max_retries + 1),
|
|
225
|
+
wait=wait_exponential(
|
|
226
|
+
multiplier=1,
|
|
227
|
+
min=self._config.retry_min_wait,
|
|
228
|
+
max=self._config.retry_max_wait
|
|
229
|
+
),
|
|
230
|
+
before_sleep=before_sleep_log(logger, 30),
|
|
231
|
+
after=log_retry,
|
|
232
|
+
reraise=True,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _handle_error_response(response, data: dict, log_context: dict) -> Optional[APIError]:
|
|
237
|
+
"""
|
|
238
|
+
Обработка ошибочных ответов API.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
response: Объект ответа
|
|
242
|
+
data: Данные ответа
|
|
243
|
+
log_context: Контекст для логирования
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
APIError или None если ошибка не критическая
|
|
247
|
+
"""
|
|
248
|
+
code = data.get("code", response.status)
|
|
249
|
+
message = data.get("message", "Unknown error")
|
|
250
|
+
details = data.get("details", [])
|
|
251
|
+
|
|
252
|
+
log_context.update({
|
|
253
|
+
"error_code": code,
|
|
254
|
+
"error_message": message,
|
|
255
|
+
"error_details": details,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
logger.error(f"Ошибка API: {message}", extra=log_context)
|
|
259
|
+
|
|
260
|
+
error_map = {
|
|
261
|
+
400: APIClientError,
|
|
262
|
+
403: APIForbiddenError,
|
|
263
|
+
404: APINotFoundError,
|
|
264
|
+
409: APIConflictError,
|
|
265
|
+
500: APIServerError,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
exc_class = error_map.get(response.status, APIError)
|
|
269
|
+
return exc_class(code, message, details)
|
|
270
|
+
|
|
271
|
+
async def _request(
|
|
272
|
+
self,
|
|
273
|
+
method: Literal["post", "get", "put", "delete"] = "post",
|
|
274
|
+
api_name: str = "Ozon Seller API",
|
|
275
|
+
api_version: str = "v1",
|
|
276
|
+
endpoint: str = "",
|
|
277
|
+
json: Optional[dict[str, Any]] = None,
|
|
278
|
+
params: Optional[dict[str, Any]] = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
"""
|
|
281
|
+
Выполняет HTTP-запрос к API Ozon с учетом ограничения запросов.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
method: HTTP метод запроса
|
|
285
|
+
api_name: Название API
|
|
286
|
+
api_version: Версия API
|
|
287
|
+
endpoint: Конечная точка API
|
|
288
|
+
json: Данные для отправки в формате JSON
|
|
289
|
+
params: Query parameters
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Ответ от API в формате JSON
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
APIClientError: При ошибках клиента (400)
|
|
296
|
+
APIForbiddenError: При ошибках доступа (403)
|
|
297
|
+
APINotFoundError: При отсутствии ресурса (404)
|
|
298
|
+
APIConflictError: При конфликте данных (409)
|
|
299
|
+
APIServerError: При ошибках сервера (500)
|
|
300
|
+
APIError: При прочих ошибках
|
|
301
|
+
"""
|
|
302
|
+
if self._closed:
|
|
303
|
+
raise RuntimeError("API-клиент остановлен")
|
|
304
|
+
|
|
305
|
+
if not self._rate_limiter_manager or not self._session_manager:
|
|
306
|
+
raise RuntimeError("API-клиент не инициализирован")
|
|
307
|
+
|
|
308
|
+
url = f"{self._config.base_url}/{api_version}/{endpoint}"
|
|
309
|
+
|
|
310
|
+
log_context = {
|
|
311
|
+
"api_name": api_name,
|
|
312
|
+
"client_id": self._client_id,
|
|
313
|
+
"method": method,
|
|
314
|
+
"endpoint": endpoint,
|
|
315
|
+
"api_version": api_version,
|
|
316
|
+
"url": url,
|
|
317
|
+
"has_payload": json is not None,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
logger.debug("Отправка запроса к API", extra=log_context)
|
|
321
|
+
|
|
322
|
+
await self._ensure_registered()
|
|
323
|
+
|
|
324
|
+
limiter_config = RateLimiterConfig(
|
|
325
|
+
max_requests=self._config.max_requests_per_second,
|
|
326
|
+
)
|
|
327
|
+
rate_limiter = await self._rate_limiter_manager.get_limiter(
|
|
328
|
+
self._client_id, limiter_config
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
retry_decorator = self._create_retry_decorator()
|
|
332
|
+
|
|
333
|
+
async def _execute_request():
|
|
334
|
+
"""Выполнение запроса."""
|
|
335
|
+
async with self._session_manager.get_session(
|
|
336
|
+
self._client_id, self._api_key, self._instance_id
|
|
337
|
+
) as session:
|
|
338
|
+
async with rate_limiter:
|
|
339
|
+
try:
|
|
340
|
+
async with session.request(
|
|
341
|
+
method, url, json=json, params=params
|
|
342
|
+
) as response:
|
|
343
|
+
data = await response.json()
|
|
344
|
+
|
|
345
|
+
log_context.update({
|
|
346
|
+
"status_code": response.status,
|
|
347
|
+
"response_size": len(str(data))
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
if response.status >= 400:
|
|
351
|
+
error = self._handle_error_response(response, data, log_context)
|
|
352
|
+
if error:
|
|
353
|
+
raise error
|
|
354
|
+
|
|
355
|
+
logger.debug("Успешный ответ от API", extra=log_context)
|
|
356
|
+
return data
|
|
357
|
+
|
|
358
|
+
except asyncio.TimeoutError:
|
|
359
|
+
logger.error("Таймаут запроса к API", extra=log_context)
|
|
360
|
+
raise APIError(408, "Request timeout")
|
|
361
|
+
except asyncio.CancelledError:
|
|
362
|
+
logger.warning("Запрос к API отменен", extra=log_context)
|
|
363
|
+
raise
|
|
364
|
+
except (aiohttp.ClientError, ConnectionError, OSError) as e:
|
|
365
|
+
log_context.update({
|
|
366
|
+
"error_type": type(e).__name__,
|
|
367
|
+
"error_message": str(e)
|
|
368
|
+
})
|
|
369
|
+
logger.error(
|
|
370
|
+
f"Сетевая ошибка при выполнении запроса к API: {str(e)}",
|
|
371
|
+
extra=log_context
|
|
372
|
+
)
|
|
373
|
+
raise APIError(0, f"Network error: {str(e)}")
|
|
374
|
+
|
|
375
|
+
_execute_request_retry = retry_decorator(_execute_request)
|
|
376
|
+
return await _execute_request_retry()
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
async def get_active_client_ids(cls) -> list[str]:
|
|
380
|
+
"""Возвращает список client_id с активными экземплярами."""
|
|
381
|
+
if cls._rate_limiter_manager:
|
|
382
|
+
return await cls._rate_limiter_manager.get_active_client_ids()
|
|
383
|
+
return list()
|
|
384
|
+
|
|
385
|
+
@classmethod
|
|
386
|
+
async def get_rate_limiter_stats(cls) -> dict[str, int]:
|
|
387
|
+
"""Возвращает статистику по ограничителям запросов."""
|
|
388
|
+
if cls._rate_limiter_manager:
|
|
389
|
+
return await cls._rate_limiter_manager.get_instance_stats()
|
|
390
|
+
return dict()
|
|
391
|
+
|
|
392
|
+
@classmethod
|
|
393
|
+
async def get_detailed_stats(cls) -> dict[str, dict[str, Any]]:
|
|
394
|
+
"""Возвращает детальную статистику."""
|
|
395
|
+
if cls._rate_limiter_manager:
|
|
396
|
+
return await cls._rate_limiter_manager.get_limiter_stats()
|
|
397
|
+
return dict()
|
|
398
|
+
|
|
399
|
+
@classmethod
|
|
400
|
+
async def get_method_limiter_stats(cls) -> dict[str, dict[str, Any]]:
|
|
401
|
+
"""Возвращает статистику по ограничителям методов."""
|
|
402
|
+
if cls._method_rate_limiter_manager:
|
|
403
|
+
return await cls._method_rate_limiter_manager.get_limiter_stats()
|
|
404
|
+
return dict()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class APIError(Exception):
|
|
2
|
+
"""Базовое исключение для ошибок API."""
|
|
3
|
+
def __init__(self, code: int, message: str, details: list | None = None):
|
|
4
|
+
self.code = code
|
|
5
|
+
self.message = message
|
|
6
|
+
self.details = details or []
|
|
7
|
+
super().__init__(f"API Error {code}: {message}")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIClientError(APIError):
|
|
11
|
+
"""Ошибка 400: Неверный параметр."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIForbiddenError(APIError):
|
|
16
|
+
"""Ошибка 403: Доступ запрещён."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APINotFoundError(APIError):
|
|
21
|
+
"""Ошибка 404: Ответ не найден."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIConflictError(APIError):
|
|
26
|
+
"""Ошибка 409: Конфликт запроса."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIServerError(APIError):
|
|
31
|
+
"""Ошибка 500: Внутренняя ошибка сервера."""
|
|
32
|
+
pass
|