fix(domclick): каждая попытка добора получает исход (#3332)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m15s

Обрыв «пул прокси пуст» уходил из цикла между attempted++ и записью исхода,
поэтому тождество attempted = enriched + failed + blocked ломалось ровно на 1
(прод: 5 прогонов с diff=1). Исход честно failed, не blocked: к площадке не
ходили, отказала наша инфраструктура — тот же разряд, что у транспортных сбоев
(#3283); причина прогона по-прежнему в no_proxy_stop=1 + mark_failed.

Та же дыра закрыта у save_detail_enrichment(...) is False: карточка разобрана,
но строки уже нет — попытка была, исхода не было.

Closes #3332
This commit is contained in:
bot-backend 2026-09-02 17:35:48 +05:00
parent d8b7de2cf6
commit e2e7e17f95
2 changed files with 201 additions and 0 deletions

View file

@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3332, та же дыра в тождестве, что и у пустого пула: карточку
# забрали, а строки уже нет (удалена/деактивирована между
# снимком и UPDATE) — попытка была, исхода не было. Тихо
# терять её нельзя: расхождение читается как потерянный блок.
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s — карточка "
"разобрана, но UPDATE не нашёл строку id=%d",
run_id,
source_url,
listing_id,
)
consecutive_blocks = 0
consecutive_soft = 0
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
# #3332: попытка уже посчитана в attempted (строка выше), а этот
# выход из цикла шёл мимо ВСЕХ исходов — тождество
# attempted = enriched + failed + blocked ломалось ровно на 1
# (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
# Исход честно failed, а не blocked: к площадке не ходили, это
# отказ нашей стороны — тот же разряд, что у транспортных сбоев
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",

View file

@ -0,0 +1,179 @@
"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332).
`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в
разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool`
`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому
текущая попытка оставалась без исхода: прод дал 5 прогонов с
`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как
потерянный отказ площадки, хотя площадку не трогали вовсе.
Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша
инфраструктура тот же разряд, что у транспортных сбоев (`_is_transport_failure`,
#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не
подменяет.
Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул
False карточка разобрана, но строки уже нет; попытка была, исхода не было.
Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение».
Харнесс зеркалит tests/test_3196_domclick_ban_kind.py.
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
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.domclick_exceptions import DomClickBlockedError # noqa: E402
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
from app.tasks.domclick_detail_backfill import ( # noqa: E402
DomClickDetailBackfillResult,
run_domclick_detail_backfill,
)
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc() -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = {"CAS_ID": "123"}
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
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()
return MagicMock(return_value=instance)
def _empty_pool_block() -> DomClickBlockedError:
"""Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в
DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в
__cause__ и снаружи выглядит блокировкой."""
blocked = DomClickBlockedError("browser fetch failed")
blocked.__cause__ = NoProxyAvailableError("domclick")
return blocked
async def _run(
fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
) -> tuple[DomClickDetailBackfillResult, MagicMock]:
db = _mock_db(_make_snapshot(snapshot))
runs = MagicMock()
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, fetch),
patch(_SAVE, return_value=save_ok),
patch(_SLEEP, new_callable=AsyncMock),
):
counters = await run_domclick_detail_backfill(
db,
run_id=3332,
params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5},
)
return counters, runs
def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None:
outcomes = counters.enriched + counters.failed + counters.blocked
assert counters.attempted == expected_attempted, (
f"attempted={counters.attempted}, ожидали {expected_attempted} попыток"
)
assert counters.attempted == outcomes, (
f"тождество нарушено: attempted={counters.attempted}, "
f"сумма исходов={outcomes} (enriched={counters.enriched} "
f"failed={counters.failed} blocked={counters.blocked}), "
f"потеряно {counters.attempted - outcomes} попыток без исхода"
)
@pytest.mark.asyncio
async def test_empty_pool_mid_run_keeps_identity() -> None:
"""2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed."""
fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()])
counters, runs = await _run(fetch, snapshot=5)
_assert_identity(counters, expected_attempted=3)
assert counters.failed == 1, (
f"failed={counters.failed}: оборванная пустым пулом попытка должна быть "
"засчитана отказом НАШЕЙ стороны"
)
assert counters.blocked == 0, (
f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — "
"начислять блок значит лгать про причину (#3283)"
)
# Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком.
runs.mark_failed.assert_called_once()
assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1
@pytest.mark.asyncio
async def test_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
fetch = AsyncMock(return_value=MagicMock())
counters, _ = await _run(fetch, snapshot=2, save_ok=False)
_assert_identity(counters, expected_attempted=2)
assert counters.enriched == 0 and counters.failed == 2, (
f"enriched={counters.enriched} failed={counters.failed}: "
"непрошедший UPDATE — не успех и не блок"
)
@pytest.mark.asyncio
async def test_blocked_and_enriched_counted_once() -> None:
"""Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""
fetch = AsyncMock(
side_effect=[
MagicMock(),
DomClickBlockedError("refused", status=403),
MagicMock(),
]
)
counters, _ = await _run(fetch, snapshot=3)
_assert_identity(counters, expected_attempted=3)
assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), (
f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, "
"ожидали 2/1/0 — правка #3332 не должна начислять исход дважды"
)