Merge pull request 'fix(tradein/avito): брейкер добора считает долю только по отказам площадки — сайдкар/пустой пул не рвут прогон и не красят его banned' (#3367) from fix/3288-breaker-block-kind into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m3s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Failing after 32s
Deploy Trade-In / perimeter-smoke (push) Has been skipped
Deploy Trade-In / deploy-status (push) Failing after 1s

This commit is contained in:
bot-backend 2026-09-05 19:38:48 +00:00
commit 7afaa12d75
4 changed files with 505 additions and 9 deletions

View file

@ -56,6 +56,8 @@ from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass, field
from scraper_kit.orchestration.runs import BAN_KIND_PLATFORM
@dataclass
class BlockRatioBreaker:
@ -88,7 +90,26 @@ class BlockRatioBreaker:
"""Знаменатель ratio-критерия: сколько попыток уже влезло в окно."""
return len(self._window)
def record_block(self) -> None:
def record_block(self, kind: str = BAN_KIND_PLATFORM) -> None:
"""Блок в числитель ratio-критерия — ТОЛЬКО отказ площадки (#3288).
`kind` диагноз того же исключения, что уходит в counters.ban_kinds
(ban_kind_of_exception). Всё, что не 'platform' (в первую очередь
BAN_KIND_INFRA: отказ нашего сайдкара, пустой пул), отказ НАШЕЙ стороны:
площадка его не показывала, и рвать по нему прогон значит объявить бан там,
где площадка молчала. Прогон 5425 оборвался по доле на 41 infra из 48
«блоков» при 41 успешно обогащённой карточке.
Такой отказ идёт ровно туда же, куда record_failure(): в ЗНАМЕНАТЕЛЬ окна
(доля платформенных блоков от него честно падает), мимо серии и safety-net.
Отдельно важно, что серию он не двигает: иначе safety-net на снапшоте
короче окна рвал бы прогон по тем же infra-отказам, только другим
критерием. Дефолт 'platform' сохраняет поведение вызывающих, которые вид
не считают.
"""
if kind != BAN_KIND_PLATFORM:
self.record_failure()
return
self._consecutive_blocks += 1
self._window.append(True)

View file

@ -911,23 +911,48 @@ def mark_backfill_finished(
return
if blocked and (aborted_by_blocks or produced == 0):
reason = (
f"backfill-honest-status: {source} остановлен блоками источника — "
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
# Counter() принимает и Collection (считает элементы — старые set/list-вызовы),
# и Mapping (копирует кратности как есть — census от вызывающего) одним и тем
# же конструктором.
census = Counter(ban_kinds)
if census:
counters["ban_kinds"] = dict(census) # type: ignore[assignment]
dominant = _dominant_ban_kind(census)
if dominant == BAN_KIND_INFRA and produced > 0:
# #3288: 'banned' означает «площадка нас заблокировала» — и читается так
# же (триггер ротации IP, алерты, разбор простоя). Прогон 5425 при 41
# infra из 48 «блоков» честно обогатил 41 карточку — его оборвал брейкер
# по доле, а не площадка, — и всё равно рапортовал «остановлен блоками
# источника». Понижаем ровно этот случай: диагноз infra И прогон работу
# сделал → 'done'.
#
# Нулевой прогон с infra остаётся 'banned' — контракт #2764/#3196:
# там диагноз несёт ban_kind строки ('infra'), а статус говорит «прогон
# оборван отказами». Понижать его до 'failed' по одному лишь диагнозу
# опаснее исходного дефекта: при пустом/отсутствующем census (источник
# видов не различает) dominant='unknown', а настоящий бан площадки,
# опознанный как infra по 5xx, спрятался бы под «нашей поломкой».
reason = (
f"backfill-honest-status: {source} оборван брейкером на отказах НАШЕГО "
f"тракта — blocked={blocked} (диагноз '{BAN_KIND_INFRA}' у большинства), "
f"обогащено {enriched} из {attempted} попыток{hint} (#3288)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_done(db, run_id, counters)
return
reason = (
f"backfill-honest-status: {source} остановлен блоками источника — "
f"blocked={blocked}, обогащено {enriched} из {attempted} попыток{hint} (#2674)"
)
logger.error("%s run_id=%d", reason, run_id)
mark_banned(
db,
run_id,
reason,
counters,
ban_kind=_dominant_ban_kind(census),
ban_kind=dominant,
)
return

View file

@ -80,6 +80,7 @@ from scraper_kit.providers.avito.detail import (
save_detail_enrichment,
)
from scraper_kit.providers.avito.serp import AvitoScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from scraper_kit.snapshot_writer import upsert_listing_snapshot
from sqlalchemy import text
from sqlalchemy.orm import Session
@ -173,6 +174,34 @@ def _top_failure(census: Counter[str]) -> str | None:
return f"{reason} ({hits} из {sum(census.values())})"
def _iter_causes(exc: BaseException) -> list[BaseException]:
"""Цепочка причин исключения, без зацикливания (копия приёма из
domclick_detail_backfill там же он и обкатан на #3283)."""
seen: set[int] = set()
out: list[BaseException] = []
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
out.append(cur)
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return out
def _caused_by_empty_pool(exc: BaseException) -> bool:
"""Прячется ли за этим «блоком» пустой пул прокси (#3288, как #3283 у домклика).
`NoProxyAvailableError` документирован ровно как «НАША инфраструктура, не
внешний блок», и поднимается ДО HTTP-запроса: к площадке мы не ходили вовсе.
Сюда он попадает под видом блокировки, потому что fetch_detail заворачивает
в `AvitoSidecarUnavailableError` любое исключение фетча.
Опора ТИП в цепочке `__cause__`/`__context__`, а не подстрока «no proxy
available» в тексте: текст обёртки её действительно содержит, но ровно так
#3272 уже один раз объявил блоком пользовательское описание квартиры.
"""
return any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(exc))
# Провайдер mobileproxy.space почти всегда возвращает "rt" (секунды на переподключение
# канала после ротации) в RotationResult.reconnect_delay_s -- см. app.services.
# proxy_rotation docstring. Дефолт нужен ТОЛЬКО если провайдер его не прислал
@ -529,6 +558,8 @@ async def run_avito_detail_backfill(
consecutive_failures = 0
aborted_by_blocks = False
abort_reason: str | None = None
# #3288: прогон оборван пустым пулом прокси — «нечем ходить», а не бан.
no_proxy_stop = False
do_sleep = False
items_since_warm = 0
# Счётчик попыток (успех ИЛИ отказ — оба тратят бюджет IP одинаково, см.
@ -765,10 +796,33 @@ async def run_avito_detail_backfill(
)
except (AvitoBlockedError, AvitoRateLimitedError) as e:
breaker.record_block()
# #3288: пустой пул — не блок и не отказ площадки: запрос не
# уходил вовсе, следующая карточка упрётся ровно в то же самое.
# Сюда он приезжает под видом блокировки, потому что fetch_detail
# заворачивает в AvitoSidecarUnavailableError любое исключение
# фетча. Исход честно failed (тот же разряд, что транспортные
# сбои ниже) — тождество attempted = enriched + blocked + gone +
# failed остаётся целым (#3338), а причину несёт no_proxy_stop=1
# и mark_failed с текстом про пул, как у домклика после #3283.
if _caused_by_empty_pool(e):
counters.failed += 1
logger.error(
"avito_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",
run_id,
counters.enriched,
counters.attempted,
)
no_proxy_stop = True
break
ban_kind = ban_kind_of_exception(e)
# #3288: в окно доли идёт только 'platform' — infra (отказ нашего
# сайдкара) уходит в знаменатель, см. BlockRatioBreaker.record_block.
breaker.record_block(ban_kind)
counters.blocked += 1
failure_census[_failure_signature(e)] += 1
block_ban_kinds[ban_kind_of_exception(e)] += 1
block_ban_kinds[ban_kind] += 1
do_sleep = False
# #3251/#3283g: на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError; НЕ на
@ -982,6 +1036,29 @@ async def run_avito_detail_backfill(
# и без этого ключа "banned" опять не отличить по причине (#3178).
if abort_reason is not None:
current_counters["abort_reason"] = abort_reason # type: ignore[assignment]
if no_proxy_stop:
# #3288 (как #3283 у домклика): остановка из-за пустого пула — НЕ блок,
# поэтому и не aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись
# будет утверждать про площадку то, чего не было. Это отказ нашей стороны.
current_counters["no_proxy_stop"] = 1
runs_mod.mark_failed(
db,
run_id,
"пул прокси пуст — к площадке не ходили (#3288)",
current_counters,
)
logger.info(
"avito_detail_backfill: run_id=%d FINISHED (пул пуст) -- attempted=%d "
"enriched=%d blocked=%d gone=%d failed=%d duration=%.1fs",
run_id,
counters.attempted,
counters.enriched,
counters.blocked,
counters.gone,
counters.failed,
counters.duration_sec,
)
return counters
runs_mod.mark_backfill_finished(
db,
run_id,

View file

@ -0,0 +1,373 @@
"""Вид блока решает обрыв по доле и статус прогона (#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_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'"
)