Retry transient МойСклад GET requests and improve error logging
This commit is contained in:
parent
63ed6f6e38
commit
63378d6480
@ -1,6 +1,8 @@
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -8,6 +10,9 @@ import httpx
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MoySkladError(RuntimeError):
|
||||
pass
|
||||
|
||||
@ -26,6 +31,9 @@ class MoySkladClient:
|
||||
DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2"
|
||||
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
||||
ASSORTMENT_ENTITY_TYPES = ("product", "variant", "bundle", "service")
|
||||
GET_RETRY_DELAYS = (2.0, 5.0, 10.0)
|
||||
CONNECT_TIMEOUT_SECONDS = 30.0
|
||||
READ_TIMEOUT_SECONDS = 90.0
|
||||
|
||||
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
|
||||
self.base_url = (
|
||||
@ -62,7 +70,12 @@ class MoySkladClient:
|
||||
base_url=self.base_url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
timeout=httpx.Timeout(
|
||||
connect=self.CONNECT_TIMEOUT_SECONDS,
|
||||
read=self.READ_TIMEOUT_SECONDS,
|
||||
write=self.CONNECT_TIMEOUT_SECONDS,
|
||||
pool=self.CONNECT_TIMEOUT_SECONDS,
|
||||
),
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
@ -76,32 +89,94 @@ class MoySkladClient:
|
||||
self.close()
|
||||
|
||||
def _get(self, path: str, *, params: dict[str, Any] | None = None) -> dict:
|
||||
try:
|
||||
response = self.client.get(path.lstrip("/"), params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
detail = self._error_detail(exc.response)
|
||||
if status in {401, 403}:
|
||||
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
|
||||
elif status == 400:
|
||||
message = "МойСклад не принял запрос. Проверьте access token и права пользователя."
|
||||
elif status == 429:
|
||||
message = "МойСклад временно ограничил частоту запросов."
|
||||
else:
|
||||
message = f"МойСклад вернул ошибку HTTP {status}."
|
||||
if detail:
|
||||
message = f"{message} Ответ сервиса: {detail}"
|
||||
raise MoySkladError(message) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise MoySkladError(
|
||||
"Не удалось получить корректный ответ от МойСклад."
|
||||
) from exc
|
||||
endpoint = path.lstrip("/")
|
||||
retryable_errors = (
|
||||
httpx.ReadTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.ReadError,
|
||||
)
|
||||
total_attempts = len(self.GET_RETRY_DELAYS) + 1
|
||||
payload: Any = None
|
||||
|
||||
for attempt in range(1, total_attempts + 1):
|
||||
try:
|
||||
response = self.client.get(endpoint, params=params)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
break
|
||||
except retryable_errors as exc:
|
||||
log_context = {
|
||||
"event": "commerce.moysklad.get_retry",
|
||||
"endpoint": endpoint,
|
||||
"request_offset": (params or {}).get("offset"),
|
||||
"request_limit": (params or {}).get("limit"),
|
||||
"attempt": attempt,
|
||||
"max_attempts": total_attempts,
|
||||
"error_type": type(exc).__name__,
|
||||
"failure_reason": str(exc)[:500],
|
||||
}
|
||||
if attempt >= total_attempts:
|
||||
logger.error(
|
||||
"MoySklad GET failed after retries: %s",
|
||||
type(exc).__name__,
|
||||
extra={
|
||||
**log_context,
|
||||
"event": "commerce.moysklad.get_failed",
|
||||
"retries_exhausted": True,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise MoySkladError(
|
||||
"МойСклад не ответил после повторных попыток."
|
||||
) from exc
|
||||
|
||||
retry_delay = self.GET_RETRY_DELAYS[attempt - 1]
|
||||
logger.warning(
|
||||
"MoySklad GET transient failure %s; retrying in %.1f seconds",
|
||||
type(exc).__name__,
|
||||
retry_delay,
|
||||
extra={**log_context, "retry_in_seconds": retry_delay},
|
||||
)
|
||||
time.sleep(retry_delay)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise MoySkladError(self._http_status_message(exc.response)) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.error(
|
||||
"MoySklad GET failed without retry: %s",
|
||||
type(exc).__name__,
|
||||
extra={
|
||||
"event": "commerce.moysklad.get_failed",
|
||||
"endpoint": endpoint,
|
||||
"request_offset": (params or {}).get("offset"),
|
||||
"request_limit": (params or {}).get("limit"),
|
||||
"error_type": type(exc).__name__,
|
||||
"failure_reason": str(exc)[:500],
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise MoySkladError(
|
||||
"Не удалось получить корректный ответ от МойСклад."
|
||||
) from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise MoySkladError("МойСклад вернул ответ неожиданного формата.")
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _http_status_message(cls, response: httpx.Response) -> str:
|
||||
status = response.status_code
|
||||
if status in {401, 403}:
|
||||
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
|
||||
elif status == 400:
|
||||
message = (
|
||||
"МойСклад не принял запрос. Проверьте access token и права пользователя."
|
||||
)
|
||||
elif status == 429:
|
||||
message = "МойСклад временно ограничил частоту запросов."
|
||||
else:
|
||||
message = f"МойСклад вернул ошибку HTTP {status}."
|
||||
detail = cls._error_detail(response)
|
||||
return f"{message} Ответ сервиса: {detail}" if detail else message
|
||||
|
||||
def product_images(
|
||||
self,
|
||||
entity_type: str,
|
||||
|
||||
@ -119,10 +119,80 @@ def test_moysklad_client_uses_vendor_accept_header():
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
try:
|
||||
assert client.client.headers["Accept"] == "application/json;charset=utf-8"
|
||||
assert client.client.timeout.read == 90.0
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_moysklad_client_retries_transient_get_errors_on_same_page(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
attempts = []
|
||||
failures = [httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError]
|
||||
|
||||
def handler(request):
|
||||
attempts.append(str(request.url))
|
||||
if failures:
|
||||
error_type = failures.pop(0)
|
||||
raise error_type("temporary upstream failure", request=request)
|
||||
return httpx.Response(200, json={"rows": []}, request=request)
|
||||
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with patch("commerce.integrations.moysklad.time.sleep") as sleep:
|
||||
try:
|
||||
payload = client._get(
|
||||
"/entity/demand",
|
||||
params={"limit": 100, "offset": 1600},
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert payload == {"rows": []}
|
||||
assert len(attempts) == 4
|
||||
assert len(set(attempts)) == 1
|
||||
assert "offset=1600" in attempts[0]
|
||||
assert [call.args[0] for call in sleep.call_args_list] == [2.0, 5.0, 10.0]
|
||||
|
||||
|
||||
def test_moysklad_client_logs_original_error_before_wrapping(
|
||||
settings,
|
||||
caplog,
|
||||
):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
|
||||
def handler(request):
|
||||
raise httpx.ReadTimeout("upstream headers timeout", request=request)
|
||||
|
||||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||||
client.client.close()
|
||||
client.client = httpx.Client(
|
||||
base_url=client.base_url,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with (
|
||||
patch("commerce.integrations.moysklad.time.sleep"),
|
||||
pytest.raises(MoySkladError) as raised,
|
||||
):
|
||||
try:
|
||||
client._get(
|
||||
"/entity/demand",
|
||||
params={"limit": 100, "offset": 1600},
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert isinstance(raised.value.__cause__, httpx.ReadTimeout)
|
||||
assert any(
|
||||
getattr(record, "error_type", "") == "ReadTimeout"
|
||||
and getattr(record, "request_offset", None) == 1600
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
|
||||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||||
image_content = _jpeg_bytes()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user