fix(ptica): дрейф формы ответа DaData не роняет весь гео-проход (#2464) #2942
2 changed files with 99 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue