From 6c3743ade9fba128574317b5a6c4fe1288d86880 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 21:39:44 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=B4=D1=80=D0=B5=D0=B9=D1=84=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20DaData=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D0=B5=D1=81=D1=8C=20=D0=B3=D0=B5=D0=BE-=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=85=D0=BE=D0=B4=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_suggest_geocode` брал вложенный словарь как data = suggestions[0].get("data") or {} `or {}` ловит только falsy. Истинное не-словарное значение (список, строка, число — дрейф контракта) доходило до `.get` и поднимало AttributeError. Летел он НАРУЖУ. В suggest попадают из clean_address по фолбэку 401/403 («Feature CLEAN disabled» — реальный прод-инцидент 03.07, на него есть тест), и этот вызов стоит УЖЕ ЗА пределами её try/except. У вызывающего гео-прохода (objective_backfill._geocode, строка 864) обёртки нет вовсе. То есть один такой ответ убил бы весь проход целиком, а не один адрес. Непоследовательность видна в самом файле: `payload`, `suggestions[0]` и `item` проверяются через isinstance, а `data` — нет. Защита пропала ровно на уровень глубже. Про масштаб честно: за 14 суток в логах бэкенда и воркера НЕТ ни одного упоминания dadata_client — гео-проход ручной и не запускался. Но путь не заблокирован ничем внешним, и я сам записал в #2929 критерий приёмки, который требует этот проход запустить. Тесты: три параметра (список / строка / число) красные на origin/main с `AttributeError: 'list' object has no attribute 'get'`; контроль на правильную форму зелёный с обеих сторон — иначе «починка» могла бы свестись к «всегда None». pytest tests/services: 3078 passed, 14 skipped, rc=0 --- backend/app/services/dadata_client.py | 19 ++++- backend/tests/services/test_dadata_client.py | 81 ++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/backend/app/services/dadata_client.py b/backend/app/services/dadata_client.py index c6ed661d..e28ed546 100644 --- a/backend/app/services/dadata_client.py +++ b/backend/app/services/dadata_client.py @@ -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: diff --git a/backend/tests/services/test_dadata_client.py b/backend/tests/services/test_dadata_client.py index 9b8f0e63..06805759 100644 --- a/backend/tests/services/test_dadata_client.py +++ b/backend/tests/services/test_dadata_client.py @@ -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 -- 2.45.3