fix(dadata): фолбэк на suggest/address при отключённой фиче CLEAN (#2250)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m54s
Deploy / build-worker (push) Successful in 2m56s
Deploy / deploy (push) Successful in 1m33s

This commit is contained in:
bot-backend 2026-07-03 03:42:40 +00:00
parent cc9516ab3d
commit 55b13e2b13
2 changed files with 118 additions and 6 deletions

View file

@ -14,7 +14,14 @@ DADATA_API_SECRET).
сторона (geo-pass) трактует None как «нет геокода» reject-кандидат, проход не сторона (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 from __future__ import annotations
@ -29,6 +36,7 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address" 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: 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") logger.warning("dadata_client: HTTP 429 — quota exceeded")
return None return None
if status in (401, 403): if status in (401, 403):
logger.error( # Фича CLEAN у токена может быть отключена (баланс стандартизации) —
"dadata_client: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN/SECRET.", # фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен).
status, logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status)
) return _suggest_geocode(address.strip(), token)
return None
if status >= 400: if status >= 400:
logger.warning("dadata_client: HTTP %d для %r", status, address[:60]) logger.warning("dadata_client: HTTP %d для %r", status, address[:60])
return None return None
@ -133,3 +140,56 @@ def clean_address(address: str) -> tuple[float, float] | None:
return None return None
return (lat, lon) 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)

View file

@ -90,3 +90,55 @@ def test_clean_address_timeout_returns_none() -> None:
client.post.side_effect = httpx.TimeoutException("timed out") client.post.side_effect = httpx.TimeoutException("timed out")
with patch("httpx.Client", return_value=client): with patch("httpx.Client", return_value=client):
assert clean_address("г Екатеринбург, ул X, 1") is None 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