From 55b13e2b138fd62353619feca2959730aa2b15c6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 03:42:40 +0000 Subject: [PATCH] =?UTF-8?q?fix(dadata):=20=D1=84=D0=BE=D0=BB=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=B0=20suggest/address=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B8=D1=87=D0=B5=20CLEAN=20(#2250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/dadata_client.py | 72 ++++++++++++++++++-- backend/tests/services/test_dadata_client.py | 52 ++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/backend/app/services/dadata_client.py b/backend/app/services/dadata_client.py index c74d2aeb..c6ed661d 100644 --- a/backend/app/services/dadata_client.py +++ b/backend/app/services/dadata_client.py @@ -14,7 +14,14 @@ DADATA_API_SECRET). сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не падает. -Docs: https://dadata.ru/api/clean/address/ +Прод-инцидент 2026-07-03: у токена может быть ОТКЛЮЧЕНА фича CLEAN («Feature +'CLEAN' disabled for token» — стандартизация платная, баланс исчерпан), при этом +БЕСПЛАТНЫЙ suggestions API (10k/день, только Token без X-Secret) работает и тоже +отдаёт geo_lat/geo_lon. Поэтому: cleaner → при 401/403 однократный фолбэк на +suggest/address (первая саджеста). Точность саджесты чуть ниже клинера — для +наших целей (точка на карте + bbox-гейт) достаточно. + +Docs: https://dadata.ru/api/clean/address/ · https://dadata.ru/api/suggest/address/ """ from __future__ import annotations @@ -29,6 +36,7 @@ from app.core.config import settings logger = logging.getLogger(__name__) DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address" +DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" def _credentials() -> tuple[str, str] | None: @@ -98,11 +106,10 @@ def clean_address(address: str) -> tuple[float, float] | None: logger.warning("dadata_client: HTTP 429 — quota exceeded") return None if status in (401, 403): - logger.error( - "dadata_client: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN/SECRET.", - status, - ) - return None + # Фича CLEAN у токена может быть отключена (баланс стандартизации) — + # фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен). + logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status) + return _suggest_geocode(address.strip(), token) if status >= 400: logger.warning("dadata_client: HTTP %d для %r", status, address[:60]) return None @@ -133,3 +140,56 @@ def clean_address(address: str) -> tuple[float, float] | None: return None return (lat, lon) + + +def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None: + """Фолбэк-геокод через БЕСПЛАТНЫЙ suggest/address (первая саджеста). + + Возвращает (lat, lon) из suggestions[0].data.geo_lat/geo_lon либо None. + Та же graceful-семантика, что у clean_address. + """ + headers = { + "Authorization": f"Token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + try: + with httpx.Client(timeout=settings.dadata_timeout_s) as client: + response = client.post( + DADATA_SUGGEST_URL, + headers=headers, + json={"query": address, "count": 1}, + ) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + logger.warning("dadata_client: suggest network error для %r: %s", address[:60], exc) + return None + except Exception as exc: + logger.warning("dadata_client: suggest unexpected error для %r: %s", address[:60], exc) + return None + + if response.status_code != 200: + logger.warning("dadata_client: suggest HTTP %d для %r", response.status_code, address[:60]) + return None + + try: + payload = response.json() + except ValueError as exc: + logger.warning("dadata_client: suggest невалидный JSON: %s", exc) + return None + + suggestions = payload.get("suggestions") if isinstance(payload, dict) else None + if not suggestions or not isinstance(suggestions[0], dict): + logger.info("dadata_client: suggest пусто для %r", address[:60]) + return None + + data = suggestions[0].get("data") or {} + lat = _coerce_float(data.get("geo_lat")) + lon = _coerce_float(data.get("geo_lon")) + if lat is None or lon is None: + logger.info( + "dadata_client: suggest без координат (qc_geo=%s) для %r", + data.get("qc_geo"), + address[:60], + ) + return None + return (lat, lon) diff --git a/backend/tests/services/test_dadata_client.py b/backend/tests/services/test_dadata_client.py index 5cbac20e..9b8f0e63 100644 --- a/backend/tests/services/test_dadata_client.py +++ b/backend/tests/services/test_dadata_client.py @@ -90,3 +90,55 @@ def test_clean_address_timeout_returns_none() -> None: client.post.side_effect = httpx.TimeoutException("timed out") with patch("httpx.Client", return_value=client): assert clean_address("г Екатеринбург, ул X, 1") is None + + +def test_clean_address_403_falls_back_to_suggest(monkeypatch) -> None: + """Фича CLEAN отключена (403) → фолбэк на suggest/address отдаёт координаты. + + Прод-инцидент 2026-07-03: «Feature 'CLEAN' disabled for token» при живом + бесплатном suggestions API. + """ + from app.services import dadata_client as dc + + monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") + monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") + + class _Resp: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class _Client: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, **kwargs): + if "cleaner" in url: + return _Resp(403, {"message": "Feature 'CLEAN' disabled"}) + assert "suggestions" in url + assert "X-Secret" not in kwargs.get("headers", {}) + return _Resp( + 200, + { + "suggestions": [ + { + "value": "г Екатеринбург, ул Мира, д 19", + "data": {"geo_lat": "56.8447", "geo_lon": "60.6547"}, + } + ] + }, + ) + + monkeypatch.setattr(dc.httpx, "Client", _Client) + coords = dc.clean_address("г Екатеринбург, ул Мира, 19") + assert coords is not None + assert abs(coords[0] - 56.8447) < 1e-6 and abs(coords[1] - 60.6547) < 1e-6