fix(ptica): отказ слоя НСПД больше не выглядит как «здесь ничего нет» (#2464-G/H) (#2873)
This commit is contained in:
parent
67ce65daf1
commit
597429cff9
2 changed files with 147 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue