gendesign/backend/app/services/dadata_client.py
bot-backend 55b13e2b13
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
fix(dadata): фолбэк на suggest/address при отключённой фиче CLEAN (#2250)
2026-07-03 03:42:40 +00:00

195 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon).
Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта
→ координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль
(`tradein-mvp/.../dadata.py`) импортом — это другое приложение с другим settings.
Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без
event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от
/suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN /
DADATA_API_SECRET).
Возвращает None (не raise) на любой сбой — нет credentials, пустой/короткий адрес,
сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая
сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не
падает.
Прод-инцидент 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
import logging
from typing import Any
import httpx
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:
"""(token, secret) если ОБА заданы непусто, иначе None (graceful disable)."""
token = (settings.dadata_api_token or "").strip()
secret = (settings.dadata_api_secret or "").strip()
if not token or not secret:
return None
return token, secret
def _coerce_float(value: Any) -> float | None:
"""DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def clean_address(address: str) -> tuple[float, float] | None:
"""Геокодировать один адрес через DaData /clean/address → (lat, lon).
Args:
address: свободный текст адреса (objective_lots.address).
Returns:
(geo_lat, geo_lon) как float-пара, либо None если:
- credentials не заданы в ENV (graceful disable);
- адрес пустой / короче 3 символов;
- сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx);
- невалидный JSON / пустой массив / не dict;
- DaData не вернул geo_lat/geo_lon (адрес не геокодирован).
"""
if not address or len(address.strip()) < 3:
return None
creds = _credentials()
if creds is None:
logger.debug("dadata_client: credentials не заданы — skip геокод")
return None
token, secret = creds
headers = {
"Authorization": f"Token {token}",
"X-Secret": secret,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = [address.strip()]
try:
with httpx.Client(timeout=settings.dadata_timeout_s) as client:
response = client.post(DADATA_CLEAN_URL, headers=headers, json=body)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
logger.warning("dadata_client: network error для %r: %s", address[:60], exc)
return None
except Exception as exc:
# defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит,
# НЕ роняем весь проход. Логируем на warning (не глотаем молча).
logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc)
return None
status = response.status_code
if status == 429:
logger.warning("dadata_client: HTTP 429 — quota exceeded")
return None
if status in (401, 403):
# Фича 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
try:
payload = response.json()
except ValueError as exc:
logger.warning("dadata_client: невалидный JSON: %s", exc)
return None
if not isinstance(payload, list) or not payload:
logger.info("dadata_client: пустой ответ для %r", address[:60])
return None
item = payload[0]
if not isinstance(item, dict):
logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__)
return None
lat = _coerce_float(item.get("geo_lat"))
lon = _coerce_float(item.get("geo_lon"))
if lat is None or lon is None:
logger.info(
"dadata_client: нет координат (qc_geo=%s) для %r",
item.get("qc_geo"),
address[:60],
)
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)