From 63378d6480bd97e503ed1dcc4c824b77fadd3246 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 16:40:35 +0300 Subject: [PATCH] =?UTF-8?q?Retry=20transient=20=D0=9C=D0=BE=D0=B9=D0=A1?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=20GET=20requests=20and=20improve=20err?= =?UTF-8?q?or=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/commerce/integrations/moysklad.py | 121 +++++++++++++++++---- backend/tests/commerce/test_connections.py | 70 ++++++++++++ 2 files changed, 168 insertions(+), 23 deletions(-) diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py index 25cd6cd0..a3ac8d8b 100644 --- a/backend/commerce/integrations/moysklad.py +++ b/backend/commerce/integrations/moysklad.py @@ -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, diff --git a/backend/tests/commerce/test_connections.py b/backend/tests/commerce/test_connections.py index 48d2119a..4457b55e 100644 --- a/backend/tests/commerce/test_connections.py +++ b/backend/tests/commerce/test_connections.py @@ -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()