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
|
result.grid_walk_requests += n_requests
|
||||||
db.commit()
|
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:
|
except Exception as e:
|
||||||
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
|
# Один сбойный квартал не валит весь backfill — лог + продолжаем.
|
||||||
# (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
|
|
||||||
# caller-task ловит и не ретраит, как в bulk_harvest.)
|
|
||||||
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
|
logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
continue
|
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