fix(scraper-kit): бан площадки не гасит здоровье узла для всех источников — report_ban по паре узел×источник вместо mark_health(ok=False) #3357

Merged
bot-backend merged 2 commits from fix/3288-avito-ban-per-source into main 2026-09-05 18:36:30 +00:00
6 changed files with 497 additions and 21 deletions

View file

@ -463,13 +463,18 @@ def _sidecar_ban_page_error(upstream_status: int | None = 401) -> SidecarBanPage
@pytest.mark.asyncio
async def test_fetch_detail_reports_ban_on_sidecar_ban_page() -> None:
async def test_fetch_detail_does_not_report_ban_again_on_sidecar_ban_page() -> None:
"""Sidecar-бан рапортует сам фетчер (`_report_platform_ban`, #3288) — здесь уже нет.
Повтор отсюда приходит ПОСЛЕ возможной ротации lease по fail-streak и банил бы
свежий узел. Ветка `parse_detail_html` ниже другой случай: тот детект наш,
фетчер его не видит, и там report_ban остаётся (см. тест следом за этим блоком).
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(DomClickBlockedError):
await fetch_detail(_CARD_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _CARD_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio

View file

@ -67,17 +67,23 @@ async def test_fetch_detail_sidecar_ban_page_raises_platform_block_not_infra() -
@pytest.mark.asyncio
async def test_fetch_detail_sidecar_ban_page_reports_ban() -> None:
"""Подтверждённый маркер-бан обязан попасть в scrape_proxy_source_bans через
report_ban иначе узел не ротируется и продолжает выдаваться в аренду."""
async def test_fetch_detail_sidecar_ban_page_does_not_report_ban_again() -> None:
"""Бан рапортует ФЕТЧЕР (`_report_platform_ban`), провайдер — уже нет (#3288).
До #3288 здесь стоял `assert_called_once()`, и это было верно, пока фетчер о
бане не знал. Теперь рапорт идёт из `_post_fetch` РАНЬШЕ и по правильному
lease; повтор отсюда приходит после возможной ротации по fail-streak и банит
свежий узел, который к площадке не ходил. Что бан всё-таки доезжает до пула
пин по значению в tests/test_3288_avito_ban_per_source.py (там настоящий
BrowserFetcher с фейковым пулом, а не MagicMock).
"""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=_sidecar_ban_page_error())
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
bf.report_ban.assert_called_once()
assert _ITEM_URL in bf.report_ban.call_args.args[0]
bf.report_ban.assert_not_called()
@pytest.mark.asyncio

View file

@ -0,0 +1,368 @@
"""#3288: бан площадки снимает узел АДРЕСНО (узел×источник), а не глобально по здоровью.
Прод-замер 31.0801.09: `resolve_proxy_url` отказывал ВСЕМ четырём источникам
(`ProxyPoolExhaustedError pool_total=7 banned_for_source=0 unhealthy_or_disabled=7`),
при том что живая проба тех же узлов проходила они стояли ровно на потолке
`MAX_CONSECUTIVE_FAILS`. Механизм: сайдкар на бан-странице Авито («доступ ограничен:
проблема с IP») отвечает HTTP 500 с `ban_page: true`, клиент поднимает
`SidecarBanPageError` но она подкласс `httpx.HTTPStatusError`, и общий
`except Exception` в `_post_fetch` звал `_report_fetch_result(False)`
`mark_health(ok=False)`. `mark_health` решение ГЛОБАЛЬНОЕ для пула: три бан-страницы
Авито выбивали узел и у Яндекса, и у Циана, и у Домклика, которые не возражали вовсе.
Что закрепляем по ЗНАЧЕНИЮ:
(а) подтверждённая бан-страница `AvitoBlockedError`, `ban_kind_of_exception` ==
'platform', `mark_banned(lease, source='avito')` вызван;
(б) на бан-странице `mark_health` НЕ вызван вовсе, а на настоящем транспортном сбое
(ReadTimeout) вызван с ok=False, как и раньше;
(в) `NoProxyAvailableError` (пул пуст, к площадке не ходили) ни health-fail, ни бан;
через обёртку `fetch_detail` он остаётся распознаваемым ПО ТИПУ в цепочке причин
(приём `_iter_causes`), а не по подстроке «no proxy available» (#3272);
(г) порядок веток `except`: ban-ветка стоит ДО общего `except Exception`. Пин по
значению тот же HTTP 500 с маркером и без него разводит судьбу узла;
(д) при РОТАЦИИ после бана свежий узел не наказывается за чужой отказ: дедуп по
`_banned_lease_id` сбрасывается взятием нового lease, поэтому единственный
допустимый рапорт из самого фетчера, до ротации. Второй такой же рапорт
сверху (из провайдера) банил бы узел, который к площадке не ходил;
(е) бан-страница стоит РОВНО один POST: `SidecarBanPageError` подкласс
`httpx.HTTPError`, и ретрай `fetch()` забирал её себе, удваивая fail-streak.
Зеркалит стиль tests/test_3196_domclick_ban_kind.py и
tests/test_3283_avito_sidecar_ban_is_platform_ban.py; харнесс пула (фейковый провайдер,
подмена `_client` после `__aenter__`) из tests/test_kit_browser_fetcher_proxy_pool.py.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoSidecarUnavailableError
from scraper_kit.browser_fetcher import (
_LEASE_ROTATE_AFTER_FAILS,
BrowserFetcher,
SidecarBanPageError,
)
from scraper_kit.contracts import ProxyLease
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.providers.avito.detail import fetch_detail
from scraper_kit.proxy_errors import NoProxyAvailableError
from app.services.scrape_runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM
_ITEM_URL = "https://www.avito.ru/ekaterinburg/kvartiry/x_1234567890"
_LEASE = ProxyLease(id=13, url="http://node13:8080", kind="http")
class _FakePool:
"""ProxyProvider-заглушка: пишет, ЧТО именно узнал пул об исходе (зеркало
_FakeProxyProvider из test_kit_browser_fetcher_proxy_pool.py)."""
def __init__(self, lease: ProxyLease | None = _LEASE) -> None:
self._lease = lease
self.health: list[tuple[int, bool]] = []
self.banned: list[tuple[int, str]] = []
self.touched: list[int] = []
self.released: list[int] = []
def acquire(self, provider: str) -> ProxyLease | None:
return self._lease
def release(self, lease: ProxyLease) -> None:
self.released.append(lease.id)
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
self.health.append((lease.id, ok))
def touch(self, lease: ProxyLease) -> None:
self.touched.append(lease.id)
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
self.banned.append((lease.id, source))
def _ban_page_response() -> httpx.Response:
"""РОВНО то, что отдаёт сайдкар на распознанной бан-странице (browser/server.py:1356):
HTTP 500 + `ban_page: true` + статус целевой навигации. Ответ настоящий, а не мок,
чтобы проба шла тем же трактом, что и прод (_raise_for_sidecar_status разбирает тело)."""
request = httpx.Request("POST", "http://browser:3000/fetch")
return httpx.Response(
500,
json={
"error": "BanPageDetectedError: бан-страница (проблема с IP)",
"ban_page": True,
"status": 403,
},
request=request,
)
def _plain_500_response() -> httpx.Response:
"""Настоящая внутренняя ошибка сайдкара: тот же код 500, но БЕЗ маркера бана."""
request = httpx.Request("POST", "http://browser:3000/fetch")
return httpx.Response(
500,
json={"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"},
request=request,
)
async def _fetcher(post_result: Any, pool: _FakePool) -> BrowserFetcher:
"""Фетчер с живым lease из пула и замоканным httpx-клиентом.
post_result готовый httpx.Response (вернётся) либо Exception (будет брошен).
"""
bf = BrowserFetcher(
endpoint="http://browser:3000",
source="avito",
proxy_provider=pool,
use_pool=True,
environment="production",
)
await bf.__aenter__()
client = MagicMock()
if isinstance(post_result, Exception):
client.post = AsyncMock(side_effect=post_result)
else:
client.post = AsyncMock(return_value=post_result)
client.aclose = AsyncMock(return_value=None)
bf._client = client
return bf
def _iter_causes(exc: BaseException) -> list[BaseException]:
"""Цепочка причин без зацикливания — копия приёма из domclick_detail_backfill.py."""
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
# ── (а) бан-страница → отказ ПЛОЩАДКИ + адресный бан пары «узел×источник» ──────
async def test_ban_page_raises_platform_block_and_bans_node_for_avito_only() -> None:
"""Бан-страница: AvitoBlockedError, диагноз 'platform', mark_banned(13, 'avito').
Фальсификация: до фикса SidecarBanPageError доезжала до общего except в
_post_fetch mark_health(ok=False), а строки в scrape_proxy_source_bans не
появлялось вовсе (за 7 суток 0 записей для source=avito при 58 прогонах banned).
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
assert pool.banned == [(13, "avito")], (
"узел должен быть забанен РОВНО один раз и РОВНО для avito: одно событие "
"доезжает до report_ban трижды (POST, ретрай fetch(), провайдер), а каждый "
"mark_banned растит ban_count и удлиняет отдых пары"
)
# ── (б) здоровье узла: бан-страница молчит, транспортный сбой — говорит ────────
async def test_ban_page_does_not_mark_health_failure() -> None:
"""Ключевое значение issue: на бан-странице mark_health НЕ вызывается вовсе.
Именно этот вызов гнал узел к MAX_CONSECUTIVE_FAILS и выбивал его из выдачи
ВСЕМ источникам (замер 01.09: узлы 9, 13, 14 на потолке при banned_for_source=0).
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [], f"бан площадки не должен трогать здоровье узла: {pool.health}"
assert pool.touched, "heartbeat (touch) при этом обязан идти — аренда живая"
async def test_real_transport_failure_still_marks_health_failure() -> None:
"""Контроль к предыдущему: настоящий транспортный сбой (таймаут) health-fail'ит,
как и до фикса. Без этой пары фикс неотличим от «выключили учёт здоровья»."""
pool = _FakePool()
request = httpx.Request("POST", "http://browser:3000/fetch")
bf = await _fetcher(httpx.ReadTimeout("sidecar timeout", request=request), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoSidecarUnavailableError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [(13, False), (13, False)], (
f"таймаут — сбой НАШЕГО тракта, он обязан остаться в здоровье узла "
f"(две записи: fetch() делает один ретрай): {pool.health}"
)
assert pool.banned == [], "таймаут не бан: площадка себя не показала"
# ── (в) пустой пул — ни бан, ни health-fail ───────────────────────────────────
async def test_empty_pool_is_neither_ban_nor_health_failure() -> None:
"""`NoProxyAvailableError` поднимается ДО POST'а: узла нет, площадки не видели."""
pool = _FakePool(lease=None)
bf = BrowserFetcher(
endpoint="http://browser:3000",
source="avito",
proxy_provider=pool,
use_pool=True,
environment="production",
)
with pytest.raises(NoProxyAvailableError):
await bf.__aenter__()
assert pool.health == []
assert pool.banned == []
async def test_empty_pool_through_wrapper_stays_infra_and_recognizable_by_type() -> None:
"""Обёртка `fetch_detail` даёт AvitoSidecarUnavailableError ('infra'), но исходный
тип остаётся в цепочке причин часть B (стоп «нечем ходить») ловит его через
`_iter_causes`, а НЕ подстрокой «no proxy available» (на ней обжёгся #3272)."""
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=NoProxyAvailableError("avito"))
with pytest.raises(AvitoSidecarUnavailableError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_INFRA
assert any(isinstance(c, NoProxyAvailableError) for c in _iter_causes(ei.value))
bf.report_ban.assert_not_called()
# ── (г) порядок веток except ──────────────────────────────────────────────────
def test_sidecar_ban_page_is_a_subclass_of_httpx_error() -> None:
"""Причина, по которой порядок веток вообще имеет значение (#3287): широкая ловля
забирает бан себе. Если это перестанет быть правдой тесты выше проверяют не то,
что думают."""
assert issubclass(SidecarBanPageError, httpx.HTTPStatusError)
async def test_same_500_different_marker_gives_different_node_fate() -> None:
"""Пин порядка веток по значению: ОДИН и тот же HTTP 500 разводит судьбу узла
ровно по маркеру `ban_page` в теле. Плоская 500 health-fail без бана; поставь
ban-ветку после `except Exception` и бан-страница даст тот же исход, что здесь."""
pool = _FakePool()
bf = await _fetcher(_plain_500_response(), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoSidecarUnavailableError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.health == [(13, False), (13, False)]
assert pool.banned == []
# ── (д) ротация после бана: наказан тот узел, который к площадке ходил ─────────
class _RotatingFakePool(_FakePool):
"""Пул с НЕСКОЛЬКИМИ узлами: `acquire()` выдаёт их по очереди.
Отличие от `_FakePool` выше (вечный узел 13) несущее: дедуп рапортов в
`BrowserFetcher.report_ban` держится на `_banned_lease_id`, а тот сбрасывается
взятием нового lease на неподвижном пуле дефект «второй рапорт банит свежий
узел» физически не проявляется, и тест его не видел бы.
"""
def __init__(self, leases: list[ProxyLease]) -> None:
super().__init__(lease=None)
self._queue = list(leases)
self.acquired: list[int | None] = []
def acquire(self, provider: str) -> ProxyLease | None:
lease = self._queue.pop(0) if self._queue else None
self.acquired.append(lease.id if lease is not None else None)
return lease
async def test_rotation_after_ban_does_not_ban_the_fresh_node() -> None:
"""Узел 13 поймал бан-страницу, fail-streak сменил его на 14 — забанен ТОЛЬКО 13.
Прод-сценарий: узел уже сыпался (streak на единицу ниже потолка), бан-страница
добивает его до ротации. Фальсификация: верни `browser_fetcher.report_ban(...)`
в ветку `SidecarBanPageError` в providers/avito/detail.py к тому кадру lease
уже сменился, дедуп по `_banned_lease_id` сброшен взятием нового lease, и
значение станет [(13, 'avito'), (14, 'avito')]: свежий узел получает 6-часовой
отдых (с эскалацией ban_count) за отказ, которого он не видел. При трёх узлах
в пуле это выбивает две трети выдачи с одной бан-страницы.
"""
pool = _RotatingFakePool([_LEASE, ProxyLease(id=14, url="http://node14:8080", kind="http")])
bf = await _fetcher(_ban_page_response(), pool)
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert pool.acquired == [13, 14], f"ротация обязана была произойти: {pool.acquired}"
assert pool.banned == [(13, "avito")], (
f"забанен должен быть узел, который сходил на площадку и получил отказ, "
f"а не тот, что пришёл ему на смену: {pool.banned}"
)
# ── (е) одна бан-страница — один POST ─────────────────────────────────────────
async def test_ban_page_costs_exactly_one_post() -> None:
"""Ретрай `fetch()` не имеет права трогать бан-страницу: это ответ площадки.
Фальсификация: убери `except SidecarBanPageError: raise` перед
`except (httpx.HTTPError, ...)` в `fetch()` станет 2 POST'а, а с ними и +2 к
`_lease_fail_streak` вместо +1 (ротация вдвое раньше задуманного).
"""
pool = _FakePool()
bf = await _fetcher(_ban_page_response(), pool)
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError):
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert bf._client.post.await_count == 1, (
f"бан-страницу ретраить нечем — площадка уже ответила: "
f"{bf._client.post.await_count} POST'ов"
)
# ── (ж) пустой пул при ротации не подменяет диагноз ───────────────────────────
async def test_empty_pool_during_post_ban_rotation_keeps_platform_diagnosis() -> None:
"""Ротация — best-effort: её `NoProxyAvailableError` не должна съесть бан.
Второго узла в пуле нет, поэтому `_acquire_lease()` внутри ротации падает.
Фальсификация: убери `except NoProxyAvailableError` в `_report_platform_ban`
наверх уедет она вместо `SidecarBanPageError`, провайдер завернёт её в
AvitoSidecarUnavailableError, и подтверждённый отказ площадки попадёт в
`scrape_runs.ban_kind` как 'infra' (ровно та подмена, что чинил #3283).
"""
pool = _RotatingFakePool([_LEASE])
bf = await _fetcher(_ban_page_response(), pool)
bf._lease_fail_streak = _LEASE_ROTATE_AFTER_FAILS - 1
with patch("scraper_kit.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(AvitoBlockedError) as ei:
await fetch_detail(_ITEM_URL, browser_fetcher=bf)
assert ban_kind_of_exception(ei.value) == BAN_KIND_PLATFORM
assert pool.banned == [(13, "avito")], f"бан обязан быть отрапортован до ротации: {pool.banned}"

View file

@ -420,6 +420,13 @@ class BrowserFetcher:
self._environment = environment
self._lease: ProxyLease | None = None
self._lease_fail_streak: int = 0
# id lease'а, по которому бан этому источнику УЖЕ отрапортован (#3288). Одно
# событие бана доезжает до report_ban несколько раз: _post_fetch на самой
# SidecarBanPageError, ретрай fetch() вторым POST'ом и провайдер сверху
# (providers/avito/detail.py). mark_banned на каждый вызов растит ban_count,
# то есть ОДИН факт удлинял бы отдых пары «узел×источник» кратно (docstring
# proxy_pool.mark_banned: 6ч→12ч). Сбрасывается при взятии нового lease.
self._banned_lease_id: int | None = None
# HTTP-статус ПОСЛЕДНЕЙ целевой навигации сайдкара (#3196). Публичный
# атрибут, а не возврат fetch(): поток управления менять нельзя — fetch()
# по-прежнему отдаёт str и по-прежнему не бросает там, где не бросал.
@ -532,6 +539,13 @@ class BrowserFetcher:
return await self._post_fetch(
url, origin, cookies, effective_reset, referer, fetch_mode=fetch_mode
)
except SidecarBanPageError:
# #3288: бан-страница — ОТВЕТ площадки, а не блип сайдкара; ретраить нечего.
# Ветка обязана стоять ДО httpx.HTTPError (SidecarBanPageError — его подкласс,
# см. test_sidecar_ban_page_is_a_subclass_of_httpx_error): иначе один бан
# давал второй POST тем же узлом и ВТОРОЙ инкремент _lease_fail_streak —
# ротация наступала вдвое раньше, чем задумано (_LEASE_ROTATE_AFTER_FAILS).
raise
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
@ -667,6 +681,8 @@ class BrowserFetcher:
)
raise NoProxyAvailableError(self._source)
# Новый lease — новый узел, признак «бан уже отрапортован» к нему не относится.
self._banned_lease_id = None
return lease
def _release_lease(self) -> None:
@ -722,6 +738,18 @@ class BrowserFetcher:
if self._lease is None or self._proxy_provider is None:
return
lease = self._lease
if self._banned_lease_id == lease.id:
# Тот же lease уже помечен забаненным для этого источника (#3288) —
# второй рапорт того же факта только эскалирует ban_count. Новый факт
# возможен лишь на НОВОМ lease, а его взятие сбрасывает признак.
logger.debug(
"BrowserFetcher: lease id=%d (%s) уже помечен баном — пропускаем: %s",
lease.id,
self._source,
reason,
)
return
self._banned_lease_id = lease.id
logger.warning(
"BrowserFetcher: lease id=%d (%s) BANNED — reporting to pool: %s",
lease.id,
@ -752,16 +780,22 @@ class BrowserFetcher:
"""
return self._lease.id if self._lease is not None else None
def _report_fetch_result(self, ok: bool) -> None:
def _report_fetch_result(self, ok: bool, *, health: bool = True) -> None:
"""Учесть исход ОДНОГО /fetch в здоровье текущего session-lease.
Вызывать на каждый /fetch (успешный и неуспешный) best-effort, не бросает:
- `touch()` heartbeat всегда (см. proxy_pool.touch продлевает leased_at,
чтобы reap_stale_leases не отобрал прокси у многочасовой сессии);
- `mark_health(ok)` всегда та же грануляция «на каждый /fetch», что была
- `mark_health(ok)` та же грануляция «на каждый /fetch», что была
до фикса (mark_health решает про DISABLE_THRESHOLD битого узла глобально
для пула, это НЕ session-locale решение и не должно огрубляться до
«одна оценка на всю сессию»);
«одна оценка на всю сессию»). `health=False` (#3288) выключает ТОЛЬКО этот
шаг так отказ ПЛОЩАДКИ (подтверждённая бан-страница) не копит глобальный
счётчик здоровья узла: он исправен, его отбил конкретный источник, и его
судьбу решает `mark_banned(source=...)` по паре «узел×источник»
(см. `_report_platform_ban`). До #3288 узел с тремя бан-страницами Авито
уходил из выдачи ВСЕМ источникам прод-замер 31.08: yandex/cian/domclick
получали ProxyPoolExhaustedError при banned_for_source=0 и трёх живых узлах;
- ok=False копит `_lease_fail_streak`; после `_LEASE_ROTATE_AFTER_FAILS`
подряд lease считается плохим (бан/сетевая труха) ОСОЗНАННО меняется
один раз (release старого + acquire нового), счётчик обнуляется. Следующий
@ -779,12 +813,15 @@ class BrowserFetcher:
logger.warning(
"BrowserFetcher: proxy_pool touch failed for %s", self._source, exc_info=True
)
try:
self._proxy_provider.mark_health(lease, ok)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool mark_health failed for %s", self._source, exc_info=True
)
if health:
try:
self._proxy_provider.mark_health(lease, ok)
except Exception:
logger.warning(
"BrowserFetcher: proxy_pool mark_health failed for %s",
self._source,
exc_info=True,
)
if ok:
self._lease_fail_streak = 0
@ -816,6 +853,38 @@ class BrowserFetcher:
)
self._lease = self._acquire_lease()
def _report_platform_ban(self, reason: str) -> None:
"""Исход /fetch, который сайдкар опознал как бан-страницу площадки (#3288).
Отличается от обычного провала РОВНО одним: узел не получает `mark_health(False)`.
Бан приговор паре «узел×источник» (`mark_banned`, строка в
`scrape_proxy_source_bans`, которую фильтрует `acquire(source)`), а не оценка
исправности узла: прод-замер 31.08 показал три узла на потолке
MAX_CONSECUTIVE_FAILS при banned_for_source=0 Авито выбивало их из выдачи и
Яндексу, и Циану, и Домклику, хотя живая проба тех же узлов проходила.
Порядок вызовов существенен: `report_ban` работает по ТЕКУЩЕМУ lease, а
`_report_fetch_result` может его сменить (ротация после N подряд провалов).
Fail-streak копим по-прежнему сменить сожжённый площадкой адрес полезно, и
новый lease забаненный узел уже не вернёт (`acquire` фильтрует бан по source.)
Ротация внутри `_report_fetch_result` best-effort: её `_acquire_lease()` может
поднять `NoProxyAvailableError` (прод, пул опустел mid-run), и та вылетела бы
ВМЕСТО `SidecarBanPageError`, ради которой мы сюда попали, прогон получил бы
диагноз 'infra' на подтверждённом отказе площадки. Глотаем ровно её и ровно
здесь: бан уже отрапортован (`report_ban` выше), а «нечем ходить дальше» caller
узнает на следующем /fetch, где lease действительно нужен.
"""
self.report_ban(reason)
try:
self._report_fetch_result(False, health=False)
except NoProxyAvailableError:
logger.warning(
"BrowserFetcher: пул пуст при ротации после бана (%s) — бан отрапортован, "
"поднимаем исходную ошибку площадки",
self._source,
)
async def _post_fetch(
self,
url: str,
@ -878,7 +947,20 @@ class BrowserFetcher:
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict = resp.json()
html = data["html"]
except SidecarBanPageError as exc:
# ПОРЯДОК ВЕТОК ВАЖЕН (#3288): SidecarBanPageError — подкласс
# httpx.HTTPStatusError, и общий `except Exception` ниже забирал её себе,
# отправляя подтверждённый отказ ПЛОЩАДКИ в глобальный счётчик здоровья узла.
self.last_response_status = None
self._report_platform_ban(
f"sidecar ban page (upstream={exc.upstream_status}) for {url}"
)
raise
except Exception:
# NoProxyAvailableError (пустой пул) сюда НЕ приходит: он поднимается в
# _acquire_lease ДО этого POST'а — узла, о здоровье которого можно судить,
# в этот момент просто нет. Отдельной ветки на него здесь поэтому не
# заводим: она была бы защитой, которая не может сработать (#3288 п.4).
self.last_response_status = None
self._report_fetch_result(False)
raise
@ -926,6 +1008,13 @@ class BrowserFetcher:
resp = await self._client.post(f"{self._endpoint}/fetch-json", json=payload)
_raise_for_sidecar_status(resp) # #2698: причина отказа из тела, не только код
data: dict = resp.json()
except SidecarBanPageError as exc:
# Ветка ДО общего except по той же причине, что в _post_fetch (#3288):
# бан-страница — отказ площадки, а не отказ узла.
self._report_platform_ban(
f"sidecar ban page on fetch-json (upstream={exc.upstream_status}) for {url}"
)
raise
except Exception:
self._report_fetch_result(False)
raise

View file

@ -583,7 +583,11 @@ async def fetch_detail(
# {"infra":26,"platform":7}, при этом ни одной записи для source=avito в
# scrape_proxy_source_bans — узел продолжал выдаваться в аренду). Зеркалит
# DomClick (#3239/#3283, providers/domclick/detail.py).
browser_fetcher.report_ban(f"avito detail: sidecar ban page for {full_url}")
#
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): бан рапортует сам fetcher, в
# _report_platform_ban, — раньше и по ПРАВИЛЬНОМУ lease. К моменту этого
# кадра fetcher мог уже сротироваться на свежий узел по fail-streak, и
# повторный рапорт отсюда банил бы того, кто к площадке не ходил.
raise AvitoBlockedError(
f"Avito detail: sidecar detected platform refusal for {full_url}: {exc}"
) from exc

View file

@ -552,12 +552,16 @@ async def fetch_detail(
# #3239: сайдкар опознал статический отказ площадки по маркерам тела
# (#3237 перенёс это распознавание из parse_detail_html в сайдкар — иначе
# незавершённое QRATOR-рукопожатие считалось блоком). Это ГЕНУИННЫЙ
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже,
# поэтому здесь — report_ban, в отличие от транспортной ветки следом.
# ban-сигнал, ровно того же рода, что и ветка parse_detail_html ниже.
# Статус берём из исключения: на error-пути fetch() обнулил
# last_response_status, а без 401 классификатор поставил бы 'unknown'
# вместо 'platform' и ротация IP не запустилась бы вовсе.
browser_fetcher.report_ban(f"domclick detail: sidecar ban page for {card_url}")
#
# report_ban ЗДЕСЬ НЕТ намеренно (#3288): его делает сам fetcher в
# _report_platform_ban — раньше и по ПРАВИЛЬНОМУ lease (после ротации по
# fail-streak этот кадр забанил бы уже СЛЕДУЮЩИЙ узел). Ветка
# parse_detail_html ниже — другой случай: там детект НАШ, fetcher его не
# видит, и report_ban остаётся.
raise DomClickBlockedError(
f"DomClick detail: sidecar detected platform refusal for {card_url}: {exc}",
status=exc.upstream_status,