fix(ptica): WAF-блок прерывает backfill_parcel_geom, а не гонит его дальше по кварталам (#2464) #2969

Merged
bot-backend merged 1 commit from fix/2464-backfill-waf-abort into main 2026-08-20 10:34:27 +00:00
2 changed files with 128 additions and 2 deletions

View file

@ -578,10 +578,20 @@ async def backfill_parcel_geom(
)
result.grid_walk_requests += n_requests
db.commit()
except (NspdBulkWafError, NspdBulkRateLimitError):
# #2464: бан IP / исчерпанные ретраи — НЕ «сбойный квартал». Голый
# except ниже их глотал, хотя его же комментарий обещал обратное:
# «WAF 403 пробросится из client и прервёт прогон». Прервать он не мог —
# ловил сам себя, и цикл шёл дальше по всем оставшимся кварталам, долбя
# уже блокирующий WAF и углубляя бан. Замер прода 20.08: limit=500
# участков раскладывается на 174 квартала, каждый — grid-walk по 49
# запросов, то есть до ~8500 обращений вместо остановки на первом.
# Тот же фикс, что в harvest_quarter выше (#2464-A) — там это место
# уже чинили, а это пропустили.
db.rollback()
raise
except Exception as e:
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
# caller-task ловит и не ретраит, как в bulk_harvest.)
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
db.rollback()
continue

View file

@ -0,0 +1,116 @@
"""WAF-блок обязан прерывать backfill_parcel_geom, а не переходить к следующему кварталу (#2464).
Голый `except Exception` вокруг per-quarter grid-walk ловил и `NspdBulkWafError`, при том что
его собственный комментарий обещал обратное: «WAF 403 пробросится из client и прервёт прогон».
Прервать он не мог ловил сам себя, и цикл шёл дальше по всем оставшимся кварталам.
Цена: замер прода 20.08.2026 `limit=500` участков раскладывается на **174 квартала**, каждый
grid-walk делает 49 запросов. То есть под баном один прогон выдавал бы до ~8500 обращений к уже
блокирующему WAF вместо остановки на первом ровно то поведение, что углубляет бан.
Соседний `harvest_quarter` в этом же файле починен предыдущей волной эпика (#2464-A) с тем же
доводом и прод-замером «23 job'а, 50 WAF-блоков, 0 упавших». Здесь то же место пропустили.
Проверяется ПОВЕДЕНИЕ: сколько кварталов успел тронуть прогон и вышла ли ошибка наружу.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import asyncio
from typing import Any
from unittest.mock import MagicMock, patch
# Участки из ТРЁХ разных кварталов — чтобы «пошёл дальше» было отличимо от «остановился».
_CADS = [
"66:41:0000001:1",
"66:41:0000001:2",
"66:41:0000002:1",
"66:41:0000003:1",
]
def _db() -> MagicMock:
db = MagicMock()
rows = MagicMock()
rows.scalars.return_value.all.return_value = _CADS
db.execute.return_value = rows
return db
def _run(exc: BaseException | None) -> tuple[Any, list[str]]:
"""Прогон с подменённым grid-walk. Возвращает (исключение|результат, тронутые кварталы)."""
from app.services.cadastre import bulk_harvest as mod
touched: list[str] = []
async def _fake_grid_walk(*, quarter: str, **_kw: Any):
touched.append(quarter)
if exc is not None:
raise exc
return (0, 1, 0)
with (
patch.object(mod, "_grid_walk_category", _fake_grid_walk),
patch.object(mod, "_mark_geom_unavailable", lambda *_a, **_kw: 0),
):
try:
res = asyncio.run(mod.backfill_parcel_geom(db=_db(), client=MagicMock(), limit=500))
return res, touched
except BaseException as raised:
return raised, touched
def test_waf_block_aborts_the_whole_run() -> None:
"""Первый же WAF-блок обязан выйти наружу и остановить обход.
На origin/main исключение проглатывается: прогон доходит до конца, тронув все
кварталы, и возвращает результат как ни в чём не бывало.
"""
from app.scrapers.nspd_bulk_client import NspdBulkWafError
outcome, touched = _run(NspdBulkWafError("HTTP 403 WAF block"))
assert isinstance(outcome, NspdBulkWafError), (
f"WAF-блок не вышел наружу, прогон вернул {type(outcome).__name__}"
f"обход продолжился и тронул кварталы: {touched}"
)
assert len(touched) == 1, (
f"после WAF-блока тронуто кварталов: {len(touched)} ({touched}) — "
"каждый следующий это ещё 49 запросов в уже закрытую дверь"
)
def test_rate_limit_also_aborts() -> None:
"""Исчерпанные ретраи — тоже не «сбойный квартал». Тот же контракт, что у соседа."""
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
outcome, touched = _run(NspdBulkRateLimitError("429"))
assert isinstance(outcome, NspdBulkRateLimitError)
assert len(touched) == 1
def test_ordinary_error_still_skips_only_that_quarter() -> None:
"""Контроль: обычный сбой по-прежнему пропускает ОДИН квартал и не валит прогон.
Ловит «починку», которая начала бы обрывать обход на любой ошибке тогда один
битый квартал стоил бы всей ночной докачки.
"""
outcome, touched = _run(RuntimeError("битый ответ одного квартала"))
assert not isinstance(
outcome, BaseException
), f"обычная ошибка обрушила весь прогон: {outcome!r}"
assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}"
def test_healthy_run_walks_every_quarter() -> None:
"""Контроль: без ошибок обход проходит все кварталы — правка ничего не отрезала."""
outcome, touched = _run(None)
assert not isinstance(outcome, BaseException)
assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}"