Compare commits
1 commit
main
...
fix/2464-b
| Author | SHA1 | Date | |
|---|---|---|---|
| f0284b3539 |
2 changed files with 128 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
116
backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py
Normal file
116
backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py
Normal 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}"
|
||||
Loading…
Add table
Reference in a new issue