fix(dadata): фолбэк на suggest/address при отключённой фиче CLEAN (#2250)
This commit is contained in:
parent
cc9516ab3d
commit
55b13e2b13
2 changed files with 118 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue