gendesign/tradein-mvp/backend/tests/test_3288b_block_kind_ratio.py
bot-backend 6be2bf9aaa
All checks were successful
CI Trade-In / changes (pull_request) Successful in 17s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 19s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m12s
fix(tradein/avito): серия отказов нашего тракта обрывает detail-добор статусом failed (#3439)
Прод: 7193, 7218, 7264, 7302 (15-16.09) и 6608/6631/6643/6662 (10.09) — по
1600 попыток, 1-10 карточек, ~1599 блоков, status='done', по 70+ минут
каждый. В те же окна в логах tradein-browser шторм «browser launch failed …
InvalidIP» и «/fetch 503». 6885 (13.09) — те же 1600 попыток при
ban_kinds {"infra": 1591}, но 'banned'.

Корень — регресс #3288/#3367: record_block(kind != platform) уходит в
record_failure(), то есть только в знаменатель окна. Числитель доли растёт
лишь на platform, safety-net смотрит на platform-серию, счётчика серии
infra нет вовсе. Серия отказов сайдкара любой длины не рвала прогон ничем,
а финализатор #3367 при одной карточке ставил 'done'.

- BlockRatioBreaker: infra_streak_limit и счётчик серии infra. Серию гасит
  любой ответ площадки — успех, её блок, 404. abort_reason() -> "infra_streak"
  (проверяется первым), abort_explanation() называет эту величину.
- avito_detail_backfill: порог = max_consecutive_failures (дефолт 25) — тот же
  разряд «отказ нашей стороны», что и отказы-не-блоки. На обрыве — mark_failed
  «тракт недоступен: N отказов … подряд без единого ответа площадки», в
  counters abort_reason=infra_streak и ban_kinds. Не 'banned' — площадка
  молчала; не через mark_backfill_finished — там одна карточка даёт 'done'.

Тесты по значению: 1 успех + 1599 отказов сайдкара -> attempted=26,
mark_failed, abort_reason=infra_streak, ban_kinds {"infra": 25}; обрыв ровно
на 25-м (24 — нет); 24 infra + ответ площадки (успех/блок/404) + 24 — не
обрыв; 24 infra + ответ, трижды — снапшот пройден; 25% настоящих блоков —
не обрыв; прежний контроль #3367 (20 infra + 10 успехов -> done) зелёный.

Фальсификация: без проводки порога — «assert (1600, 1600) == (26, 26)»; без
сброса серии на блоке площадки — «abort_reason='infra_streak': 24 + ответ
площадки (platform) + 24»; без ветки финала — «прогон ушёл в
backfill-honest-status: там 1 карточка превращает шторм в 'done'».

Пункт 3 задачи (browser_unfit узлу, на котором сайдкар не поднял браузер)
не сделан: это вердикт пула прокси и протокол сайдкара, отдельная задача.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:54:18 +05:00

502 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Вид блока решает обрыв по доле и статус прогона (#3288, часть B).
Прогон 5425 оборвался по доле блоков (`abort_reason='ratio'`, статус `banned`) на
48 «блоках», из которых 41 был `AvitoSidecarUnavailableError` — отказ НАШЕГО тракта,
а не площадки: та же карточка не доехала до Авито, и 41 других карточек прогон
успел честно обогатить. `record_block()` вида не принимал вовсе, поэтому в числитель
скользящего окна (#3184) падало всё подряд.
Проверка ПО ЗНАЧЕНИЮ — сравниваются `abort_reason()`, счётчики и то, какой именно
финализатор дёрнут (`mark_banned` / `mark_failed` / `mark_done`), а не «не бросило
исключение». К каждому послаблению идёт контроль на противоположную ошибку: 14
настоящих `AvitoBlockedError` из 20 обязаны рвать прогон как раньше (#3184 не
меняется), а пустой пул опознаётся по ЦЕПОЧКЕ `__cause__`, не по подстроке
«no proxy available» — на подстроке уже обжёгся #3272.
Харнесс бэкфилла зеркалит tests/test_3338_backfill_counter_identity.py, оттуда же
тождество attempted = enriched + blocked + gone + failed.
"""
from __future__ import annotations
import os
import sys
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.avito_exceptions import ( # noqa: E402
AvitoBlockedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
from app.services import scrape_runs as runs_svc # noqa: E402
from app.services.backfill_block_breaker import BlockRatioBreaker # noqa: E402
from app.services.scrape_runs import ( # noqa: E402
BAN_KIND_INFRA,
BAN_KIND_PLATFORM,
BAN_KIND_UNKNOWN,
)
from app.tasks.avito_detail_backfill import ( # noqa: E402
AvitoDetailBackfillResult,
run_avito_detail_backfill,
)
_A_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
_A_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
_A_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
_A_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
_A_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_A_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_A_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_A_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
_A_ROTATE_PROXY = "app.tasks.avito_detail_backfill.rotate_proxy"
_WINDOW = 20
_THRESHOLD = 0.7
def _breaker(snapshot_size: int = 100) -> BlockRatioBreaker:
return BlockRatioBreaker(
window_size=_WINDOW,
ratio_threshold=_THRESHOLD,
safety_min=5,
snapshot_size=snapshot_size,
)
# ── брейкер: что попадает в числитель доли ────────────────────────────────────
def test_twenty_sidecar_blocks_do_not_abort_by_ratio() -> None:
"""20 подряд отказов сайдкара при снапшоте больше окна — не 'ratio' (приёмка)."""
breaker = _breaker()
for _ in range(20):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r} при 20 infra-отказах: площадка "
"не отказывала ни разу, рвать прогон не по чему"
)
assert (breaker.window_blocks, breaker.window_len) == (0, 20), (
f"окно {breaker.window_blocks}/{breaker.window_len}: infra идёт в ЗНАМЕНАТЕЛЬ "
"(как record_failure), а не в числитель"
)
assert breaker.consecutive_blocks == 0, (
f"consecutive_blocks={breaker.consecutive_blocks}: infra не двигает серию, "
"иначе на коротком снапшоте прогон рвал бы safety-net по тем же отказам"
)
def test_fourteen_platform_blocks_of_twenty_still_abort_by_ratio() -> None:
"""Контроль на противоположную ошибку: #3184 не меняется (14/20 = порог 0.7)."""
breaker = _breaker()
for _ in range(6):
breaker.record_success()
for _ in range(14):
breaker.record_block(BAN_KIND_PLATFORM)
assert breaker.abort_reason() == "ratio", (
f"abort_reason={breaker.abort_reason()!r} при 14 настоящих блоках из 20 — "
"критерий #3184 обязан сработать ровно на пороге"
)
assert breaker.window_blocks == 14, f"числитель {breaker.window_blocks}, ожидали 14"
def test_infra_does_not_pad_the_numerator_to_the_threshold() -> None:
"""13 platform + 7 infra = 0.65, а не 1.0: infra числитель не добивает."""
breaker = _breaker()
for _ in range(7):
breaker.record_block(BAN_KIND_INFRA)
for _ in range(13):
breaker.record_block(BAN_KIND_PLATFORM)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r}: 13/20 = 0.65 ниже порога 0.7, "
"прогон рвётся только если infra зачли блоками"
)
# ── бэкфилл: тот же вопрос сквозь задачу ──────────────────────────────────────
def _fake_avito_settings() -> MagicMock:
return MagicMock(
scraper_fetch_mode="browser",
avito_detail_backfill_use_curl=False,
detail_backfill_block_ratio_window=_WINDOW,
detail_backfill_block_ratio_threshold=_THRESHOLD,
browser_http_endpoint="http://browser:9000",
avito_detail_backfill_rotate_after_attempts=15,
avito_detail_backfill_rotate_on_ban_max=0,
avito_detail_backfill_rotate_on_ban_min_gap=10,
)
def _mock_avito_db(n: int) -> MagicMock:
snapshot = [
{
"id": i + 1,
"source_url": f"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_{i + 1}",
}
for i in range(n)
]
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.request_context_reset = MagicMock()
instance.lease_id = 42
return MagicMock(return_value=instance)
async def _run_avito(
fetch_results: list[Any], *, snapshot_size: int
) -> tuple[AvitoDetailBackfillResult, MagicMock]:
runs = MagicMock()
db = _mock_avito_db(snapshot_size)
with (
patch(_A_SETTINGS, _fake_avito_settings()),
patch(_A_SESSION),
patch(_A_SCRAPER),
patch(_A_RUNS, runs),
patch(_A_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_A_FETCH, AsyncMock(side_effect=fetch_results)),
patch(_A_SAVE, return_value=True),
patch(_A_ROTATE_PROXY, AsyncMock()),
patch(_A_SLEEP, new_callable=AsyncMock),
):
counters = await run_avito_detail_backfill(
db,
run_id=3288,
params={
"batch_size": snapshot_size,
"budget_sec": 3600,
"max_consecutive_blocks": 10,
},
)
return counters, runs
def _assert_identity(c: AvitoDetailBackfillResult, *, expected_attempted: int) -> None:
outcomes = c.enriched + c.blocked + c.gone + c.failed
assert (c.attempted, outcomes) == (expected_attempted, expected_attempted), (
f"attempted={c.attempted} vs исходы={outcomes} "
f"(enriched={c.enriched} blocked={c.blocked} gone={c.gone} failed={c.failed}), "
f"ожидали {expected_attempted} — попытка без исхода (#3338)"
)
@pytest.mark.asyncio
async def test_sidecar_run_walks_the_whole_snapshot_and_is_not_banned() -> None:
"""20 отказов сайдкара + 10 успехов: снапшот пройден целиком, обрыва нет."""
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
fetches += [MagicMock() for _ in range(10)]
counters, runs = await _run_avito(fetches, snapshot_size=30)
finished = runs.mark_backfill_finished.call_args
assert finished is not None, "прогон не финализирован mark_backfill_finished"
# Приёмка #3288 первой строкой: величина, по которой прогон 5425 и оборвался.
assert finished.args[2].get("abort_reason") is None, (
f"abort_reason={finished.args[2].get('abort_reason')!r} при 20 отказах сайдкара: "
"обрыва по доле быть не могло — площадка не отказала ни разу"
)
_assert_identity(counters, expected_attempted=30)
assert (counters.blocked, counters.enriched) == (20, 10), (
f"blocked={counters.blocked} enriched={counters.enriched}: прогон обязан дойти "
"до конца снапшота, отказ нашего тракта его не рвёт"
)
assert finished.kwargs["aborted_by_blocks"] is False, (
"aborted_by_blocks=True при нулевых отказах площадки — прогон уйдёт в 'banned'"
)
assert finished.kwargs["ban_kinds"] == {BAN_KIND_INFRA: 20}, (
f"ban_kinds={finished.kwargs['ban_kinds']}: перепись диагнозов не должна "
"теряться — она и решает статус"
)
@pytest.mark.asyncio
async def test_done_run_keeps_ban_kinds_in_counters() -> None:
"""#3288 п.5: диагноз доезжает в counters и у прогона, который не 'banned'.
Прогоны 7193/7264/7302 (1600 попыток, ~1599 отказов сайдкара, status='done') легли
в БД без ban_kinds: перепись писалась в counters только на ветке бана.
"""
fetches: list[Any] = [AvitoSidecarUnavailableError("sidecar 503") for _ in range(20)]
fetches += [MagicMock() for _ in range(10)]
_, runs = await _run_avito(fetches, snapshot_size=30)
written = runs.mark_backfill_finished.call_args.args[2]
assert written.get("ban_kinds") == {BAN_KIND_INFRA: 20}, (
f"counters={written}: прогон без обрыва уходит в mark_done с этими counters — "
"без переписи диагноз отказов теряется"
)
@pytest.mark.asyncio
async def test_real_platform_bans_still_abort_by_ratio() -> None:
"""Контроль: 14 настоящих AvitoBlockedError из 20 рвут прогон как раньше."""
fetches: list[Any] = [MagicMock() for _ in range(6)]
fetches += [AvitoBlockedError("firewall/soft-block") for _ in range(14)]
counters, runs = await _run_avito(fetches, snapshot_size=30)
_assert_identity(counters, expected_attempted=20)
finished = runs.mark_backfill_finished.call_args
assert finished.args[2].get("abort_reason") == "ratio", (
f"counters={finished.args[2]}: 14 отказов площадки из 20 — ровно порог #3184"
)
assert finished.kwargs["aborted_by_blocks"] is True, "обрыв по доле обязан быть помечен"
@pytest.mark.asyncio
async def test_empty_pool_stops_run_with_its_own_reason() -> None:
"""Пустой пул — не блок: прогон завершается 'нечем ходить', а не баном."""
wrapped = AvitoSidecarUnavailableError("Avito detail browser fetch failed for https://...")
wrapped.__cause__ = NoProxyAvailableError("avito")
counters, runs = await _run_avito([wrapped], snapshot_size=30)
_assert_identity(counters, expected_attempted=1)
assert (counters.blocked, counters.failed) == (0, 1), (
f"blocked={counters.blocked} failed={counters.failed}: к площадке не ходили — "
"это отказ нашей стороны, а не блок"
)
assert runs.mark_backfill_finished.call_args is None, (
"прогон с пустым пулом не должен идти через backfill-honest-status: у него своя причина"
)
failed = runs.mark_failed.call_args
assert failed is not None, "прогон не финализирован mark_failed"
assert failed.args[3]["no_proxy_stop"] == 1, f"counters={failed.args[3]}: нет no_proxy_stop"
assert "пул прокси пуст" in failed.args[2], f"причина={failed.args[2]!r}"
@pytest.mark.asyncio
async def test_empty_pool_recognised_by_cause_not_by_text() -> None:
"""#3272: подстрока «no proxy available» в тексте — не доказательство пустого пула."""
liar = AvitoBlockedError("firewall page: no proxy available for provider='avito'")
counters, runs = await _run_avito([liar, MagicMock()], snapshot_size=2)
_assert_identity(counters, expected_attempted=2)
assert (counters.blocked, counters.enriched) == (1, 1), (
f"blocked={counters.blocked} enriched={counters.enriched}: отказ площадки с "
"«no proxy available» в тексте остаётся блоком — цепочка причин пуста"
)
assert runs.mark_failed.call_args is None, (
"прогон остановлен как «пул пуст» по подстроке в тексте — ровно ошибка #3272"
)
# ── статус прогона: диагноз infra ≠ 'banned' ──────────────────────────────────
def _finish(ban_kinds: dict[str, int], *, enriched: int) -> tuple[MagicMock, ...]:
counters = {"attempted": 90, "enriched": enriched, "blocked": 48, "gone": 0, "failed": 1}
with (
patch.object(runs_svc, "mark_banned") as banned,
patch.object(runs_svc, "mark_failed") as failed,
patch.object(runs_svc, "mark_done") as done,
):
runs_svc.mark_backfill_finished(
MagicMock(),
5425,
counters,
source="avito_detail_backfill",
aborted_by_blocks=True,
ban_kinds=ban_kinds,
)
return banned, failed, done
def test_infra_dominant_run_is_not_marked_banned() -> None:
"""41 infra из 48 при 41 обогащённой карточке — прогон 'done', не 'banned'."""
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=41)
assert banned.call_count == 0, (
"прогон помечен 'banned' при доминирующем infra — запись утверждает про "
"площадку то, чего не было"
)
assert (failed.call_count, done.call_count) == (0, 1), (
f"mark_failed={failed.call_count} mark_done={done.call_count}: 41 карточка "
"обогащена, прогон работу сделал"
)
def test_pure_infra_run_with_result_is_not_banned() -> None:
"""Основной кейс без примеси: перепись целиком infra, карточки есть → не 'banned'."""
banned, failed, done = _finish({BAN_KIND_INFRA: 20}, enriched=10)
assert (banned.call_count, failed.call_count, done.call_count) == (0, 0, 1), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: 20 отказов сайдкара при 10 обогащённых — 'done'"
)
def test_infra_dominant_run_without_result_stays_banned() -> None:
"""Контроль на ОБРАТНУЮ ошибку: ноль результата — по-прежнему 'banned' (#2764/#3196).
Диагноз infra на нулевом прогоне неотличим от бана площадки, опознанного по 5xx
(yandex, #3196): статус остаётся 'banned', честность несёт ban_kind. Понижение
до 'failed' прятало бы настоящий бан под «нашу поломку» — падало 2 теста CI.
"""
banned, failed, done = _finish({BAN_KIND_INFRA: 41, BAN_KIND_PLATFORM: 7}, enriched=0)
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: нулевой прогон обязан остаться 'banned'"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_INFRA, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}: диагноз обязан доехать"
)
def test_run_without_census_stays_banned() -> None:
"""Источник видов не различает (ban_kinds пуст) → прежнее поведение: 'banned'."""
banned, failed, done = _finish({}, enriched=41)
assert (banned.call_count, failed.call_count, done.call_count) == (1, 0, 0), (
f"mark_banned={banned.call_count} mark_failed={failed.call_count} "
f"mark_done={done.call_count}: без переписи диагноза нет — статус не меняем"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_UNKNOWN, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'unknown'"
)
def test_platform_dominant_run_is_still_banned() -> None:
"""Контроль на противоположную ошибку: настоящий бан площадки — по-прежнему 'banned'."""
banned, failed, done = _finish({BAN_KIND_PLATFORM: 41, BAN_KIND_INFRA: 7}, enriched=41)
assert banned.call_count == 1, (
f"mark_banned={banned.call_count} (failed={failed.call_count} done={done.call_count}): "
"площадка отказала в 41 блоке из 48 — это ровно 'banned'"
)
assert banned.call_args.kwargs["ban_kind"] == BAN_KIND_PLATFORM, (
f"ban_kind={banned.call_args.kwargs['ban_kind']!r}, ожидали 'platform'"
)
# ── #3439: серия отказов НАШЕГО тракта рвёт прогон своим критерием ─────────────
#
# Прогоны 7193/7264/7302: по 1600 попыток, 1-2 карточки, ~1599 отказов сайдкара
# (InvalidIP-шторм на узле), status='done'. Infra ушёл из доли (#3288), а своего
# предохранителя не получил. Порог — max_consecutive_failures (дефолт 25): тот же
# разряд «отказ нашей стороны», что и отказы-не-блоки.
_INFRA_LIMIT = 25
def _infra_breaker() -> BlockRatioBreaker:
return BlockRatioBreaker(
window_size=_WINDOW,
ratio_threshold=_THRESHOLD,
safety_min=5,
snapshot_size=1600,
infra_streak_limit=_INFRA_LIMIT,
)
def test_infra_streak_aborts_exactly_at_limit() -> None:
breaker = _infra_breaker()
breaker.record_success()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, "24 отказа подряд — ещё не порог 25"
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() == "infra_streak", (
f"abort_reason={breaker.abort_reason()!r} на 25-м отказе сайдкара подряд"
)
@pytest.mark.parametrize("answer", ["success", "platform", "gone"])
def test_any_platform_answer_resets_infra_streak(answer: str) -> None:
"""Площадка ответила (успех / её блок / 404) — тракт жив, серия с нуля."""
breaker = _infra_breaker()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
{
"success": breaker.record_success,
"platform": lambda: breaker.record_block(BAN_KIND_PLATFORM),
"gone": breaker.record_neutral,
}[answer]()
for _ in range(_INFRA_LIMIT - 1):
breaker.record_block(BAN_KIND_INFRA)
assert breaker.abort_reason() is None, (
f"abort_reason={breaker.abort_reason()!r}: 24 + ответ площадки ({answer}) + 24 — "
"серии в 25 подряд не было"
)
@pytest.mark.asyncio
async def test_sidecar_storm_stops_run_as_failed_not_done() -> None:
"""Шторм сайдкара: 1 успех, дальше только отказы — обрыв на 25-м, 'failed'."""
fetches: list[Any] = [MagicMock()]
fetches += [AvitoSidecarUnavailableError("browser unavailable 503") for _ in range(1599)]
counters, runs = await _run_avito(fetches, snapshot_size=1600)
_assert_identity(counters, expected_attempted=1 + _INFRA_LIMIT)
assert runs.mark_backfill_finished.call_args is None, (
"прогон ушёл в backfill-honest-status: там 1 карточка превращает шторм в 'done'"
)
failed = runs.mark_failed.call_args
assert failed is not None, "прогон не финализирован mark_failed"
written = failed.args[3]
assert (written["abort_reason"], written["ban_kinds"]) == (
"infra_streak",
{BAN_KIND_INFRA: _INFRA_LIMIT},
), f"counters={written}"
assert "тракт недоступен" in failed.args[2], f"причина={failed.args[2]!r}"
assert runs.mark_banned.call_args is None, "площадка не отказывала — не 'banned'"
@pytest.mark.asyncio
async def test_interleaved_platform_answers_do_not_trip_infra_stop() -> None:
"""Контроль: 24 отказа сайдкара + ответ площадки, трижды — снапшот пройден целиком."""
fetches: list[Any] = []
for answer in (AvitoBlockedError("firewall"), MagicMock(), MagicMock()):
fetches += [AvitoSidecarUnavailableError("503") for _ in range(_INFRA_LIMIT - 1)]
fetches.append(answer)
counters, runs = await _run_avito(fetches, snapshot_size=len(fetches))
_assert_identity(counters, expected_attempted=len(fetches))
finished = runs.mark_backfill_finished.call_args
assert finished is not None and finished.args[2].get("abort_reason") is None, (
f"прогон оборван: counters={finished and finished.args[2]}, "
f"mark_failed={runs.mark_failed.call_args}"
)
@pytest.mark.asyncio
async def test_quarter_platform_blocks_run_is_not_aborted() -> None:
"""Контроль: здоровый прогон с 25% настоящих блоков не рвётся ни одним критерием."""
fetches: list[Any] = []
for _ in range(10):
fetches += [MagicMock(), MagicMock(), MagicMock(), AvitoBlockedError("firewall")]
counters, runs = await _run_avito(fetches, snapshot_size=40)
_assert_identity(counters, expected_attempted=40)
finished = runs.mark_backfill_finished.call_args
assert finished is not None and finished.args[2].get("abort_reason") is None, (
f"counters={finished and finished.args[2]}: 10 блоков из 40 — рвать не по чему"
)