Retry transient МойСклад GET requests and improve error logging

This commit is contained in:
lizerk 2026-08-19 16:40:35 +03:00
parent 63ed6f6e38
commit 63378d6480
2 changed files with 168 additions and 23 deletions

View File

@ -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,

View File

@ -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()