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-кандидат, проход не
|
сторона (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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue