Merge pull request 'fix(tradein/domclick): таймаут сайдкара и пустой пул считались отказами площадки (#3283)' (#3286) from fix/3283-non-blocks-counted-as-blocks into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 7m33s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 7m33s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
03d04f13b5
2 changed files with 336 additions and 9 deletions
|
|
@ -90,9 +90,11 @@ from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
from scraper_kit.browser_fetcher import BrowserFetcher, ban_kind_from_status
|
||||||
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError
|
||||||
from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.domclick.detail import fetch_detail, save_detail_enrichment
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -182,6 +184,49 @@ def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
|
||||||
_DOMCLICK_REFUSAL_STATUSES = frozenset({401})
|
_DOMCLICK_REFUSAL_STATUSES = frozenset({401})
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_causes(exc: BaseException) -> list[BaseException]:
|
||||||
|
"""Цепочка причин исключения, без зацикливания."""
|
||||||
|
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:
|
||||||
|
"""Прячется ли за этим «блоком» пустой пул прокси (#3283).
|
||||||
|
|
||||||
|
`NoProxyAvailableError` документирован ровно как «НАША инфраструктура, не
|
||||||
|
внешний блок», и поднимается ДО HTTP-запроса: к площадке мы не ходили вовсе.
|
||||||
|
Сюда он попадает под видом блокировки, потому что `fetch_detail` заворачивает
|
||||||
|
в `DomClickBlockedError` любое исключение фетча (`except Exception`).
|
||||||
|
"""
|
||||||
|
return any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_transport_failure(exc: BaseException) -> bool:
|
||||||
|
"""Сбой нашей стороны, а не отказ площадки (#3283).
|
||||||
|
|
||||||
|
Различать по HTTP-статусу нельзя: настоящий QRATOR-челлендж приходит вообще
|
||||||
|
без статуса либо под 200, то есть неотличим от таймаута навигации. Зато
|
||||||
|
различима ПРИРОДА исключения, и разделение уже проведено в `fetch_detail`:
|
||||||
|
|
||||||
|
* ветка `except SidecarBanPageError` — сайдкар опознал страницу-отказ по
|
||||||
|
маркерам тела, это генуинный бан (там же `report_ban`);
|
||||||
|
* `parse_detail_html`, поднявший `DomClickBlockedError` — маркеры в HTML,
|
||||||
|
тоже генуинный;
|
||||||
|
* ветка `except Exception` — таймаут / 5xx сайдкара / транспорт, обёрнутый
|
||||||
|
`raise ... from exc`. Исходное исключение остаётся в `__cause__`.
|
||||||
|
|
||||||
|
Смотрим именно на третий случай: httpx-ошибка в цепочке причин. Отсутствие
|
||||||
|
статуса признаком служить не может — им как раз отличается генуинный блок.
|
||||||
|
"""
|
||||||
|
return any(isinstance(c, httpx.HTTPError) for c in _iter_causes(exc))
|
||||||
|
|
||||||
|
|
||||||
def _ban_kind_of_block(exc: DomClickBlockedError) -> str:
|
def _ban_kind_of_block(exc: DomClickBlockedError) -> str:
|
||||||
"""Диагноз одного блока по HTTP-статусу ответа площадки (#3196, #3178).
|
"""Диагноз одного блока по HTTP-статусу ответа площадки (#3196, #3178).
|
||||||
|
|
||||||
|
|
@ -241,6 +286,10 @@ async def run_domclick_detail_backfill(
|
||||||
budget_sec = float(params.get("budget_sec", 3600))
|
budget_sec = float(params.get("budget_sec", 3600))
|
||||||
request_delay_sec = float(params.get("request_delay_sec", 12.0))
|
request_delay_sec = float(params.get("request_delay_sec", 12.0))
|
||||||
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 3))
|
max_consecutive_blocks = int(params.get("max_consecutive_blocks", 3))
|
||||||
|
# #3283: отдельный, намеренно более высокий порог для сбоев нашей стороны
|
||||||
|
# (таймаут навигации, 5xx сайдкара). Тройка на них — хайртриггер: прогон 5399
|
||||||
|
# умер на трёх подряд, не увидев ни одного отказа площадки.
|
||||||
|
max_consecutive_soft = int(params.get("max_consecutive_soft_failures", 10))
|
||||||
|
|
||||||
counters = DomClickDetailBackfillResult()
|
counters = DomClickDetailBackfillResult()
|
||||||
current_counters: dict[str, int] = counters.to_dict()
|
current_counters: dict[str, int] = counters.to_dict()
|
||||||
|
|
@ -305,6 +354,9 @@ async def run_domclick_detail_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
|
# #3283: сбои НЕ блочной природы считаются отдельно и с большим порогом.
|
||||||
|
consecutive_soft = 0
|
||||||
|
no_proxy_stop = False
|
||||||
# #3212: сброс переиспользуемого context'а разрешён РОВНО ОДИН раз за прогон.
|
# #3212: сброс переиспользуемого context'а разрешён РОВНО ОДИН раз за прогон.
|
||||||
# Причина ниже, у самого вызова request_context_reset.
|
# Причина ниже, у самого вызова request_context_reset.
|
||||||
context_reset_used = False
|
context_reset_used = False
|
||||||
|
|
@ -385,6 +437,7 @@ async def run_domclick_detail_backfill(
|
||||||
if save_detail_enrichment(db, listing_id, enrichment):
|
if save_detail_enrichment(db, listing_id, enrichment):
|
||||||
counters.enriched += 1
|
counters.enriched += 1
|
||||||
consecutive_blocks = 0
|
consecutive_blocks = 0
|
||||||
|
consecutive_soft = 0
|
||||||
|
|
||||||
except DomClickParseError as e:
|
except DomClickParseError as e:
|
||||||
# Schema drift, not a block -- neutral to the block-breaker (does
|
# Schema drift, not a block -- neutral to the block-breaker (does
|
||||||
|
|
@ -399,9 +452,67 @@ async def run_domclick_detail_backfill(
|
||||||
)
|
)
|
||||||
|
|
||||||
except DomClickBlockedError as e:
|
except DomClickBlockedError as e:
|
||||||
|
ban_kind = _ban_kind_of_block(e)
|
||||||
|
|
||||||
|
# #3283 (1): пустой пул — не блок. Запрос к площадке НЕ уходил,
|
||||||
|
# и следующая карточка упрётся ровно в то же самое: продолжать
|
||||||
|
# цикл бессмысленно, а начислять блок — прямая ложь про причину.
|
||||||
|
# Прогон 5399 умер именно так: три «блока» подряд, из них два
|
||||||
|
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
|
||||||
|
if _caused_by_empty_pool(e):
|
||||||
|
logger.error(
|
||||||
|
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
|
||||||
|
"к площадке не ходили. enriched=%d attempted=%d",
|
||||||
|
run_id,
|
||||||
|
counters.enriched,
|
||||||
|
counters.attempted,
|
||||||
|
)
|
||||||
|
no_proxy_stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# #3283 (2): порог считаем по ПРИЧИНЕ, а не по числу исключений.
|
||||||
|
# Обрыв нужен, чтобы не долбить отказывающую площадку, — значит
|
||||||
|
# считать надо её отказы. Таймаут навигации и 5xx сайдкара это
|
||||||
|
# наша сторона; они идут в failed, как уже идёт DomClickParseError
|
||||||
|
# (он честно помечен «schema drift, not a block»).
|
||||||
|
if _is_transport_failure(e):
|
||||||
|
counters.failed += 1
|
||||||
|
consecutive_soft += 1
|
||||||
|
block_ban_kinds[ban_kind] += 1
|
||||||
|
logger.warning(
|
||||||
|
"domclick_detail_backfill: run_id=%d СБОЙ #%d/%d "
|
||||||
|
"(подряд=%d/%d, http=%s, kind=%s, не блок площадки): %s",
|
||||||
|
run_id,
|
||||||
|
idx + 1,
|
||||||
|
len(snapshot),
|
||||||
|
consecutive_soft,
|
||||||
|
max_consecutive_soft,
|
||||||
|
getattr(e, "status", None),
|
||||||
|
ban_kind,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
# Сторож на случай, если площадка отказывает молча (сайдкар не
|
||||||
|
# отдал статус → kind='unknown'): без него такой отказ гнал бы
|
||||||
|
# весь батч впустую. Порог выше блочного намеренно — цена
|
||||||
|
# ошибки здесь несимметрична, см. #3272.
|
||||||
|
if consecutive_soft >= max_consecutive_soft:
|
||||||
|
logger.error(
|
||||||
|
"domclick_detail_backfill: run_id=%d ABORT -- %d сбоев "
|
||||||
|
"подряд без единого отказа площадки, диагнозы: %s. "
|
||||||
|
"enriched=%d attempted=%d",
|
||||||
|
run_id,
|
||||||
|
consecutive_soft,
|
||||||
|
dict(block_ban_kinds) or "нет",
|
||||||
|
counters.enriched,
|
||||||
|
counters.attempted,
|
||||||
|
)
|
||||||
|
aborted_by_blocks = True
|
||||||
|
break
|
||||||
|
# Пауза не нужна: цикл сам спит в начале следующей итерации.
|
||||||
|
continue
|
||||||
|
|
||||||
consecutive_blocks += 1
|
consecutive_blocks += 1
|
||||||
counters.blocked += 1
|
counters.blocked += 1
|
||||||
ban_kind = _ban_kind_of_block(e)
|
|
||||||
block_ban_kinds[ban_kind] += 1
|
block_ban_kinds[ban_kind] += 1
|
||||||
# #3118 просил сброс на КАЖДЫЙ блок — и этим сам себя блокировал.
|
# #3118 просил сброс на КАЖДЫЙ блок — и этим сам себя блокировал.
|
||||||
# Пропуск QRATOR (куки qrator_jsid2 + qrator_jsr) живёт в context'е;
|
# Пропуск QRATOR (куки qrator_jsid2 + qrator_jsr) живёт в context'е;
|
||||||
|
|
@ -459,14 +570,26 @@ async def run_domclick_detail_backfill(
|
||||||
|
|
||||||
counters.duration_sec = time.monotonic() - start
|
counters.duration_sec = time.monotonic() - start
|
||||||
current_counters = counters.to_dict()
|
current_counters = counters.to_dict()
|
||||||
runs_mod.mark_backfill_finished(
|
if no_proxy_stop:
|
||||||
db,
|
# #3283: остановка из-за пустого пула — НЕ блок, поэтому и не
|
||||||
run_id,
|
# aborted_by_blocks: иначе прогон уйдёт в 'banned' и запись будет
|
||||||
current_counters,
|
# утверждать про площадку то, чего не было. Это отказ нашей стороны.
|
||||||
source="domclick_detail_backfill",
|
current_counters["no_proxy_stop"] = 1
|
||||||
aborted_by_blocks=aborted_by_blocks,
|
runs_mod.mark_failed(
|
||||||
ban_kinds=block_ban_kinds,
|
db,
|
||||||
)
|
run_id,
|
||||||
|
"пул прокси пуст — к площадке не ходили (#3283)",
|
||||||
|
current_counters,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
runs_mod.mark_backfill_finished(
|
||||||
|
db,
|
||||||
|
run_id,
|
||||||
|
current_counters,
|
||||||
|
source="domclick_detail_backfill",
|
||||||
|
aborted_by_blocks=aborted_by_blocks,
|
||||||
|
ban_kinds=block_ban_kinds,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
"domclick_detail_backfill: run_id=%d FINISHED -- attempted=%d enriched=%d "
|
||||||
"blocked=%d failed=%d duration=%.1fs",
|
"blocked=%d failed=%d duration=%.1fs",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""#3283: сбои нашей стороны считались отказами площадки и втроём рвали добор.
|
||||||
|
|
||||||
|
Прогон 5399 (30.08) умер по правилу «3 блока подряд», не обогатив ни одной карточки.
|
||||||
|
Из трёх засчитанных блоков отказом Домклика не был НИ ОДИН: два — HTTP 500 от сайдкара
|
||||||
|
(таймаут навигации), третий — пустой пул прокси, при котором запрос к площадке вообще
|
||||||
|
не отправлялся. Лог абортa сам это печатал: `диагнозы: {'unknown': 3}`.
|
||||||
|
|
||||||
|
Различать по HTTP-статусу нельзя — настоящий QRATOR-челлендж приходит без статуса и
|
||||||
|
неотличим от таймаута. Различима природа исключения: транспортные сбои приезжают
|
||||||
|
обёрнутыми вокруг httpx-ошибки (`except Exception` в fetch_detail делает
|
||||||
|
`raise DomClickBlockedError(...) from exc`), генуинные блоки — нет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
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 httpx # noqa: E402
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
|
||||||
|
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
|
||||||
|
|
||||||
|
from app.core import shutdown as _sd # noqa: E402
|
||||||
|
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill # noqa: E402
|
||||||
|
|
||||||
|
from .test_domclick_detail_backfill import ( # noqa: E402
|
||||||
|
_BROWSER_FETCHER,
|
||||||
|
_FETCH,
|
||||||
|
_RUNS,
|
||||||
|
_SAVE,
|
||||||
|
_SESSION_SVC,
|
||||||
|
_SETTINGS,
|
||||||
|
_SLEEP,
|
||||||
|
_make_snapshot,
|
||||||
|
_mock_browser_fetcher_cls,
|
||||||
|
_mock_db,
|
||||||
|
_mock_session_svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_shutdown() -> None:
|
||||||
|
_sd.reset_shutdown()
|
||||||
|
yield
|
||||||
|
_sd.reset_shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── как выглядят три природы отказа ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar_500() -> DomClickBlockedError:
|
||||||
|
"""Ровно то, что валило прогон 5399: 500 от сайдкара, обёрнутый в «блок»."""
|
||||||
|
transport = httpx.HTTPStatusError(
|
||||||
|
"Server error '500 Internal Server Error'",
|
||||||
|
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
|
||||||
|
response=httpx.Response(500),
|
||||||
|
)
|
||||||
|
exc = DomClickBlockedError("DomClick detail browser fetch failed: 500")
|
||||||
|
exc.__cause__ = transport
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_pool() -> DomClickBlockedError:
|
||||||
|
exc = DomClickBlockedError("DomClick detail browser fetch failed: no proxy")
|
||||||
|
exc.__cause__ = NoProxyAvailableError("domclick")
|
||||||
|
return exc
|
||||||
|
|
||||||
|
|
||||||
|
def _genuine_block() -> DomClickBlockedError:
|
||||||
|
"""Маркеры QRATOR в теле: причины-обёртки нет, статуса тоже может не быть."""
|
||||||
|
return DomClickBlockedError("QRATOR challenge page detected")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(side_effect, *, n: int = 10, **params):
|
||||||
|
db = _mock_db(_make_snapshot(n))
|
||||||
|
runs = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
|
||||||
|
patch(_SESSION_SVC, _mock_session_svc({"CAS_ID": "123"})),
|
||||||
|
patch(_RUNS, runs),
|
||||||
|
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
|
||||||
|
patch(_FETCH, AsyncMock(side_effect=side_effect)),
|
||||||
|
patch(_SAVE, MagicMock(return_value=True)),
|
||||||
|
patch(_SLEEP, new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
result = await run_domclick_detail_backfill(
|
||||||
|
db, run_id=1, params={"batch_size": n, "budget_sec": 3600, **params}
|
||||||
|
)
|
||||||
|
return result, runs
|
||||||
|
|
||||||
|
|
||||||
|
# ── пустой пул ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_stops_immediately_without_counting_a_block() -> None:
|
||||||
|
"""Пул пуст → остановка на первой же карточке, блоков ноль.
|
||||||
|
|
||||||
|
Продолжать цикл бессмысленно: каждая следующая упрётся в то же самое.
|
||||||
|
"""
|
||||||
|
result, _ = await _run(_empty_pool())
|
||||||
|
assert result.attempted == 1
|
||||||
|
assert result.blocked == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_pool_is_not_reported_as_a_platform_ban() -> None:
|
||||||
|
"""Прогон уходит в failed, а не в banned: запись не должна утверждать про
|
||||||
|
площадку то, чего не было — к ней не ходили."""
|
||||||
|
_, runs = await _run(_empty_pool())
|
||||||
|
runs.mark_failed.assert_called_once()
|
||||||
|
runs.mark_backfill_finished.assert_not_called()
|
||||||
|
assert "#3283" in runs.mark_failed.call_args.args[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ── транспортные сбои ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_three_sidecar_500s_no_longer_abort_the_run() -> None:
|
||||||
|
"""Ровно сценарий 5399: три 500 подряд больше не рвут прогон.
|
||||||
|
|
||||||
|
Порог существует, чтобы не долбить ОТКАЗЫВАЮЩУЮ площадку. Три таймаута
|
||||||
|
сайдкара про площадку не говорят ничего.
|
||||||
|
"""
|
||||||
|
result, _ = await _run(
|
||||||
|
[_sidecar_500(), _sidecar_500(), _sidecar_500(), None, None, None, None, None, None, None],
|
||||||
|
n=10,
|
||||||
|
)
|
||||||
|
assert result.attempted == 10, "прогон обязан дойти до конца батча"
|
||||||
|
assert result.blocked == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sidecar_500_counts_as_failure_not_block() -> None:
|
||||||
|
"""Транспорт идёт в failed — туда же, куда давно идёт DomClickParseError."""
|
||||||
|
result, _ = await _run([_sidecar_500()] + [None] * 4, n=5)
|
||||||
|
assert result.failed == 1
|
||||||
|
assert result.blocked == 0
|
||||||
|
assert result.enriched == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_recovers_after_transport_failures() -> None:
|
||||||
|
"""Сбой не должен отравлять остаток батча: следующие карточки обогащаются."""
|
||||||
|
result, _ = await _run([_sidecar_500(), None, _sidecar_500(), None, None], n=5)
|
||||||
|
assert result.enriched == 3
|
||||||
|
assert result.failed == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── генуинный блок по-прежнему рвёт прогон ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_genuine_blocks_still_abort_after_threshold() -> None:
|
||||||
|
"""Главная страховка правки: настоящие отказы площадки считаются как раньше."""
|
||||||
|
result, runs = await _run([_genuine_block()] * 10, n=10, max_consecutive_blocks=3)
|
||||||
|
assert result.blocked == 3
|
||||||
|
assert result.attempted == 3
|
||||||
|
runs.mark_backfill_finished.assert_called_once()
|
||||||
|
assert runs.mark_backfill_finished.call_args.kwargs["aborted_by_blocks"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_transport_failures_do_not_reset_genuine_block_streak() -> None:
|
||||||
|
"""Сбой между блоками не должен обнулять счётчик отказов площадки.
|
||||||
|
|
||||||
|
Иначе чередование «блок, таймаут, блок, таймаут…» держало бы прогон вечно
|
||||||
|
против площадки, которая нас уже не пускает.
|
||||||
|
"""
|
||||||
|
result, _ = await _run(
|
||||||
|
[_genuine_block(), _sidecar_500(), _genuine_block(), _sidecar_500(), _genuine_block()],
|
||||||
|
n=5,
|
||||||
|
max_consecutive_blocks=3,
|
||||||
|
)
|
||||||
|
assert result.blocked == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── сторож на молчаливый отказ ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_long_run_of_transport_failures_still_aborts() -> None:
|
||||||
|
"""Если площадка отказывает молча, прогон обязан остановиться — просто позже.
|
||||||
|
|
||||||
|
Без этого сторожа правка превратила бы хайртриггер в отсутствие тормоза.
|
||||||
|
"""
|
||||||
|
result, _ = await _run([_sidecar_500()] * 30, n=30, max_consecutive_soft_failures=10)
|
||||||
|
assert result.attempted == 10
|
||||||
|
assert result.failed == 10
|
||||||
|
|
||||||
|
|
||||||
|
async def test_soft_threshold_is_configurable() -> None:
|
||||||
|
"""Порог читается из params — расписание должно уметь его двигать."""
|
||||||
|
result, _ = await _run([_sidecar_500()] * 30, n=30, max_consecutive_soft_failures=4)
|
||||||
|
assert result.attempted == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_success_resets_the_soft_streak() -> None:
|
||||||
|
"""Удачная карточка обнуляет серию — иначе редкие сбои копились бы за весь батч
|
||||||
|
и рвали прогон, в котором всё хорошо."""
|
||||||
|
seq = ([_sidecar_500()] * 3 + [None]) * 3 + [None] * 6
|
||||||
|
result, _ = await _run(seq, n=18, max_consecutive_soft_failures=4)
|
||||||
|
assert result.attempted == 18
|
||||||
|
assert result.enriched == 9
|
||||||
Loading…
Add table
Reference in a new issue