fix(ptica): дрейф формы ответа DaData не роняет весь гео-проход (#2464) #2942

Merged
bot-backend merged 1 commit from fix/2464-dadata-data-shape into main 2026-08-19 16:59:50 +00:00
2 changed files with 99 additions and 1 deletions

View file

@ -182,7 +182,24 @@ def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None:
logger.info("dadata_client: suggest пусто для %r", address[:60])
return None
data = suggestions[0].get("data") or {}
# #2464: `or {}` ловит только falsy. Если DaData отдаст в `data` список или
# строку (дрейф контракта), `.get` ниже поднимет AttributeError — а он летит
# НАРУЖУ: сюда попадают из clean_address по фолбэку 401/403 (строка 112), то
# есть уже ЗА пределами её try/except, и у вызывающего гео-прохода
# (objective_backfill._geocode) обёртки тоже нет. Один такой ответ уронил бы
# весь проход целиком, а не один адрес.
#
# Соседние уровни в этом же файле проверяются через isinstance — `payload`,
# `suggestions[0]`, `item` в clean_address. Защита пропала ровно на один
# уровень глубже.
data = suggestions[0].get("data")
if not isinstance(data, dict):
logger.warning(
"dadata_client: suggest data не dict (%s) для %r",
type(data).__name__,
address[:60],
)
return None
lat = _coerce_float(data.get("geo_lat"))
lon = _coerce_float(data.get("geo_lon"))
if lat is None or lon is None:

View file

@ -142,3 +142,84 @@ def test_clean_address_403_falls_back_to_suggest(monkeypatch) -> None:
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
# ── #2464: дрейф формы `data` не должен ронять весь гео-проход ──────────────────
def _suggest_fallback_client(data_field: Any):
"""Клиент, у которого cleaner отдаёт 403 (фолбэк на suggest), а suggest —
ответ с произвольным содержимым поля `data`."""
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"})
return _Resp(200, {"suggestions": [{"value": "адрес", "data": data_field}]})
return _Client
@pytest.mark.parametrize(
"bad_data",
[
pytest.param(["не словарь"], id="список"),
pytest.param("строка", id="строка"),
pytest.param(42, id="число"),
],
)
def test_suggest_fallback_survives_non_dict_data(monkeypatch, bad_data: Any) -> None:
"""`data` не словарь → None, а не AttributeError.
Раньше стояло `data = suggestions[0].get("data") or {}` `or {}` ловит только
falsy, поэтому истинное не-словарное значение доходило до `.get` и роняло
AttributeError. Летел он НАРУЖУ: в suggest попадают из clean_address по
фолбэку 403, то есть уже за пределами её try/except, а у вызывающего
(objective_backfill._geocode) обёртки нет вовсе один такой ответ убил бы
весь гео-проход, а не один адрес.
Соседние уровни того же файла проверяются через isinstance (`payload`,
`suggestions[0]`, `item`); защита пропала ровно на уровень глубже.
"""
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")
monkeypatch.setattr(dc.httpx, "Client", _suggest_fallback_client(bad_data))
assert dc.clean_address("г Екатеринбург, ул Мира, 19") is None
def test_suggest_fallback_still_reads_a_proper_dict(monkeypatch) -> None:
"""Контроль: правильная форма по-прежнему отдаёт координаты (зелёный с обеих
сторон иначе «починка» могла бы свестись к «всегда None»)."""
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")
monkeypatch.setattr(
dc.httpx,
"Client",
_suggest_fallback_client({"geo_lat": "56.8447", "geo_lon": "60.6547"}),
)
coords = dc.clean_address("г Екатеринбург, ул Мира, 19")
assert coords is not None
assert abs(coords[0] - 56.8447) < 1e-6