From f0284b353916248e1f47e54cafcbb8a610eaa9e1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 15:08:14 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20WAF-=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20backfi?= =?UTF-8?q?ll=5Fparcel=5Fgeom,=20=D0=B0=20=D0=BD=D0=B5=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=20=D0=B5=D0=B3=D0=BE=20=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=B5=20=D0=BF=D0=BE=20=D0=BA=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B0=D0=BC=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Голый 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 --- backend/app/services/cadastre/bulk_harvest.py | 14 ++- .../cadastre/test_2464_backfill_waf_aborts.py | 116 ++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 8d89b0da..8109fb04 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -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 diff --git a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py new file mode 100644 index 00000000..e9d7a146 --- /dev/null +++ b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py @@ -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}" -- 2.45.3