From d43dd7d2575f5b01618a4fe0b562ccb00d67c2b9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 13 Aug 2026 21:59:30 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=8F=20=D0=9D=D0=A1=D0=9F=D0=94=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B3=D0=BB=D1=8F=D0=B4=D0=B8=D1=82=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=C2=AB=D0=B7=D0=B4=D0=B5=D1=81=D1=8C=20=D0=BD=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_features_in_bbox_grid глушил ЛЮБОЕ исключение ячейки (warning + continue) и возвращал []. Бан IP по WAF или падение слоя целиком становились неотличимы от честного пустого ответа. Замер на проде: nspd_quarter_dumps — 669 строк, у 124 territorial_zones_count=0, и у 50 из них соседний legacy-слой данные всё-таки нашёл. То есть это не «зон нет», а «мы не узнали». Правка — зеркало уже исправленного близнеца в app/scrapers/nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror): - NspdBulkWafError → немедленный re-raise (продолжать обход = углублять бан); - NspdBulkServerError считаем отдельно; если 5xx были И ни одна ячейка не прошла — бросаем вместо ложного пустого; - прочие (сетевые/parse) ошибки ячейки по-прежнему не валят обход. get_riasurt_sverdl_in_bbox ловит NspdBulkServerError и сохраняет прежний контракт («пустой list если слой пуст / упал»), но НЕ ловит WafError. Ограничение записано в коде честно: наружу оттуда «упал» и «пусто» по-прежнему неразличимы — у функции нет канала для флага. Докстрока search_by_quarter обещала атомарность, которой не было; теперь описывает, что бросается и где partial-success ВОЗМОЖЕН (#2464-H). Тесты двусторонние: против main 2 из 3 падают (waf_403_aborts_grid, all_cells_5xx_raises), третий — контроль partial_5xx_keeps_data — зелёный с обеих сторон. Один хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864). Refs #2464 --- backend/app/services/scrapers/nspd_client.py | 91 +++++++++++++++++-- backend/tests/scrapers/test_nspd_grid_walk.py | 64 +++++++++++++ 2 files changed, 147 insertions(+), 8 deletions(-) diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 9742dd43..95a9019b 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -559,7 +559,11 @@ class NSPDClient: """ # Импортируем здесь чтобы избежать circular import: # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain) - from app.scrapers.nspd_bulk_client import NSPDBulkClient + from app.scrapers.nspd_bulk_client import ( + NSPDBulkClient, + NspdBulkServerError, + NspdBulkWafError, + ) xmin, ymin, xmax, ymax = bbox width_m = xmax - xmin @@ -607,10 +611,45 @@ class NSPDClient: results = await asyncio.gather(*tasks, return_exceptions=True) features: list[NSPDFeature] = [] - for r in results: - if isinstance(r, Exception): - logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r) + # #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход + # возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился + # неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669 + # с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл. + # Ниже — зеркало уже исправленного близнеца + # nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror). + server_errors = 0 + ok_cells = 0 + first_server_error: NspdBulkServerError | None = None + for idx, r in enumerate(results): + if isinstance(r, NspdBulkWafError): + # 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход + # бессмысленно, а пустой результат соврал бы про отсутствие зон. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s", + layer_id, + idx, + r, + ) + raise r + if isinstance(r, NspdBulkServerError): + server_errors += 1 + if first_server_error is None: + first_server_error = r + logger.debug( + "get_features_in_bbox_grid layer=%d cell=%d server error: %s", + layer_id, + idx, + r, + ) continue + if isinstance(r, Exception): + # Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ + # считаем server-side, иначе сеть ложно поднимет layer_failed. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r + ) + continue + ok_cells += 1 for bulk_feat in r: raw = { "id": bulk_feat.id, @@ -618,6 +657,20 @@ class NSPDClient: "properties": bulk_feat.properties, } features.append(NSPDFeature.from_raw(raw)) + + # Были server-side отказы И ни одна ячейка не прошла — лёг слой или + # весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто + # ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого. + if server_errors > 0 and ok_cells == 0 and first_server_error is not None: + logger.warning( + "get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный " + "(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого", + layer_id, + effective_n, + effective_n, + server_errors, + ) + raise first_server_error return features raw_features = asyncio.run(_run_grid()) @@ -679,6 +732,10 @@ class NSPDClient: dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId (пустой list если слой пуст / упал). Стабильная форма для caller'а. """ + # Локальный импорт по той же причине, что в get_features_in_bbox_grid: + # nspd_client ← nspd_bulk_client дало бы circular import на top-level. + from app.scrapers.nspd_bulk_client import NspdBulkServerError + layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys()) result: dict[int, list[NSPDFeature]] = {} for layer_id in layer_ids: @@ -686,7 +743,15 @@ class NSPDClient: feats = self.get_features_in_bbox_grid( layer_id, bbox_3857, grid_n=grid_n, step_m=step_m ) - except (NspdLiteError, NspdLiteWafError) as exc: + except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc: + # #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError + # («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение — + # пустой список на слой, — потому что именно это обещает докстрока + # («пустой list если слой пуст / упал») и на это опирается вызывающий. + # NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать + # обход остальных слоёв значит углублять бан. + # Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему + # неразличимы — у функции нет канала для флага. Отдельным заходом. logger.warning( "get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем", layer_id, @@ -840,9 +905,19 @@ class NSPDClient: `layers_fetched` в этом случае содержит только `('search',)`. Raises: - NspdLiteWafError при 403/429 на любом из layer запросов — caller - должен делать backoff. Partial-success НЕ возвращается; вся - операция атомарна (failure → exception). + NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings) — + caller должен делать backoff. + NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) — + бан IP, обход прерывается сразу. + NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и + ни одна ячейка не прошла) — иначе вернулся бы пустой список, + неотличимый от честного «здесь ничего нет». + + До #2464-G это место обещало атомарность, которой не было: grid-walk + глушил любое исключение ячейки и отдавал []. Теперь обещание верно + для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН: + если часть ячеек упала по сети, а часть прошла, вернётся то, что + собралось, с warning'ом в лог на каждую упавшую ячейку. Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 377ba20c..26d8e625 100644 --- a/backend/tests/scrapers/test_nspd_grid_walk.py +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid: # 4 cells: 1 error + 3 good_feat → 1 unique feature assert any(f.feature_id == "feat-ok" for f in result) + # ── #2464-G: отказ слоя больше не маскируется пустым результатом ────────── + + def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]: + """Прогнать grid-walk с подменённым wms_feature_info.""" + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + return NSPDClient().get_features_in_bbox_grid( + 36328, self.BBOX, grid_n=grid_n, step_m=1.0 + ) + + def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None: + """403 WAF на ячейке — бан IP, обход прерывается. + + До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет», + неотличимое от честного пустого слоя. На проде это 124 дампа из 669 + с territorial_zones_count=0, у 50 из которых соседний legacy-слой + данные всё-таки нашёл. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkWafError("HTTP 403 WAF") + + with pytest.raises(NspdBulkWafError): + self._grid(_wms) + + def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None: + """Все ячейки упали с 5xx — слой лёг целиком, а не «пуст».""" + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkServerError("HTTP 500 ServiceException") + + with pytest.raises(NspdBulkServerError): + self._grid(_wms) + + def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None: + """Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем. + + Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ. + Именно этот тест ловил бы обратную крайность — «чуть что, роняем обход». + """ + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"}) + call_n: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_n[0] += 1 + if call_n[0] <= 2: + raise NspdBulkServerError("HTTP 500 ServiceException") + return [good_feat] + + result = self._grid(_wms) + assert any( + f.feature_id == "feat-ok" for f in result + ), "успешные ячейки должны попасть в результат, даже если часть слоя упала" + def test_returns_nspd_feature_instances(self) -> None: """Метод возвращает list[NSPDFeature] а не NSPDBulkFeature.""" bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"})