Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
f0284b3539 fix(ptica): WAF-блок прерывает backfill_parcel_geom, а не гонит его дальше по кварталам (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m20s
Голый except Exception вокруг per-quarter grid-walk ловил и NspdBulkWafError, при
том что его собственный комментарий обещал обратное:

    # (WAF 403 пробросится из client и прервёт прогон — это ожидаемо,
    #  caller-task ловит и не ретраит, как в bulk_harvest.)

Прервать он не мог — ловил сам себя. Цикл шёл дальше по всем оставшимся кварталам,
долбя уже блокирующий WAF.

Цена. Замер прода 20.08: limit=500 участков раскладывается на 174 квартала, каждый
grid-walk делает 49 запросов. Под баном один прогон выдал бы до ~8500 обращений в
закрытую дверь вместо остановки на первом — ровно то поведение, что углубляет бан.

Соседний harvest_quarter в ЭТОМ ЖЕ файле починен предыдущей волной эпика (#2464-A)
с тем же доводом и прод-замером «23 job'а, 50 WAF-блоков, 0 упавших — то есть бан
ни разу не остановил сбор». Здесь то же место пропустили; правка приводит четвёртый
сайт к трём соседним.

Про причастность к текущему бану НСПД (#2956) не утверждаю: задача
backfill_parcel_geom_task в beat-расписании отсутствует, запускается вручную —
проверено. Но запуск её сейчас, при живом 403, дал бы ровно описанный сценарий.

Тест проверяет ПОВЕДЕНИЕ — вышла ли ошибка наружу и сколько кварталов успел
тронуть прогон. Против origin/main:

  WAF-блок      → вернулся GeomBackfillResult(quarters_scanned=3) вместо ошибки
  rate-limit    → то же самое
  обычный сбой пропускает ОДИН квартал  — контроль, зелёный с обеих сторон
  здоровый прогон обходит все кварталы  — контроль, зелёный с обеих сторон

Первый контроль не для симметрии: он ловит «починку», которая начала бы обрывать
обход на любой ошибке — тогда один битый квартал стоил бы всей ночной докачки.

Прогоны: tests/services/cadastre + tests/services/scrapers — 309 passed rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:08:14 +05: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}"