fix(tradein/proxy): проба спрашивает каждую площадку, вердикт пишется на пару «узел × источник» (#2800 B) (#2803)
All checks were successful
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 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 2m0s

This commit is contained in:
bot-backend 2026-08-09 18:28:37 +00:00
parent 7cd8c63b89
commit 08bb9d6549
5 changed files with 717 additions and 51 deletions

View file

@ -91,6 +91,28 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
«Непригоден для браузера» это НЕ исключение из пула: acquire() лишь отдаёт такой
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
Проба на ПАРУ «узел × источник» (#2800, продолжение #2723):
- #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом
для всех узлов на один зашитый адрес robots.txt Авито. Прокси-узел не «жив/мёртв»
вообще: замер на проде 09.08.2026 узел id=1 отдаёт 200 на Авито и Яндексе и 500
NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и
свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен».
- Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может
достаться (browser_fetcher.PROBE_SOURCES affinity), по РАБОЧЕМУ хосту площадки
(apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а
`bff-search-web.domclick.ru`, куда ходит сбор, 500).
- Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой
сущности не заводим эта ровно про пару и её уже читает acquire): подтверждённый
отказ строка бана с reason=_PROBE_BAN_REASON, успех снятие СВОЕЙ строки.
Чужие строки (бан, распознанный боевым сбором) проба не трогает robots.txt
площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать
дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»).
- Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение
«браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО
креста: хоть одна зелёная площадка ok; все красные транспортом провал узла.
Отказ одной площадки узел глобально не пятнает иначе мы бы своими руками
вернули то самое схлопывание диагнозов.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@ -125,6 +147,7 @@ __all__ = [
"mark_banned",
"mark_browser_health",
"mark_health",
"mark_source_probe",
"reap_stale_leases",
"release",
"run_proxy_healthcheck",
@ -195,6 +218,26 @@ BROWSER_PROBE_MINUTES = 360
# намеренно не обновляется (см. mark_browser_health).
BROWSER_UNFIT_THRESHOLD = 2
# ── проба на пару «узел × источник» (#2800) ──────────────────────────────────
# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт):
# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито;
# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть
# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch;
# - одна проба 918 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов
# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at).
# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при
# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было.
# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и
# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором
# acquire снова выдаёт узел, не спросив.
#
# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её
# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная
# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка
# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка
# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре.
_PROBE_BAN_REASON = "probe:browser"
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
@ -682,9 +725,14 @@ def mark_browser_health(
return "fail"
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> None:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
`reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по
умолчанию 'banned:<source>' (бан распознан боевым сбором), у браузерной пробы
_PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому
clear_source_bans умеет фильтровать по ней (`only_reason`).
Отличается от `mark_health(ok=False)`: та инкрементит consecutive_fails и
авто-disable'ит только после DISABLE_THRESHOLD ПОДРЯД неудач (мягкая деградация —
транзиентный сбой должен пережить пару неудач). Здесь причина УЖЕ надёжно
@ -820,7 +868,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
{
"proxy_id": proxy_id,
"source": source,
"reason": f"banned:{source}",
"reason": reason or f"banned:{source}",
"base_hours": SOURCE_BAN_BASE_HOURS,
"max_hours": SOURCE_BAN_MAX_HOURS,
"max_fails": MAX_CONSECUTIVE_FAILS,
@ -866,7 +914,14 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
)
def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None, reason: str) -> int:
def clear_source_bans(
db: Session,
proxy_id: int,
*,
source: str | None = None,
reason: str,
only_reason: str | None = None,
) -> int:
"""Снять баны узла по источникам (#2600 п.2). Returns число снятых строк.
ЗАЧЕМ ОТДЕЛЬНАЯ РУЧКА: до п.2 ложный бан лечился оператором через
@ -889,6 +944,13 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
SOURCE_BAN_BASE_HOURS.
`reason` идёт только в лог (человекочитаемый повод «manual enable», «ip rotated»).
`only_reason` ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые
написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое
свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан,
распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация
IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной
целиком. None снимать всё, как и раньше.
"""
rows = db.execute(
text(
@ -896,10 +958,11 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
DELETE FROM scrape_proxy_source_bans
WHERE proxy_id = CAST(:proxy_id AS bigint)
AND (CAST(:source AS text) IS NULL OR source = CAST(:source AS text))
AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text))
RETURNING source
"""
),
{"proxy_id": proxy_id, "source": source},
{"proxy_id": proxy_id, "source": source, "only_reason": only_reason},
).fetchall()
db.commit()
if rows:
@ -913,6 +976,75 @@ def clear_source_bans(db: Session, proxy_id: int, *, source: str | None = None,
return len(rows)
def mark_source_probe(
db: Session,
proxy_id: int,
*,
source: str,
ok: bool,
fail_kind: str | None = None,
detail: str = "",
) -> str:
"""Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800).
Пара то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или
не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает
`acquire(source)` `scrape_proxy_source_bans`; новой сущности не заводим.
КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница
другая здесь судится ПАРА, а не узел):
- "sidecar" общая зависимость лежит, к паре отношения не имеет "ignored".
Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз);
- "proxy" через этот узел до площадки не доходит транспорт
(NS_ERROR_PROXY_*, camoufox не поднялся) бан пары;
- "page" дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку
(200 + «Ошибка Циан» вместо robots.txt) тоже бан пары.
Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для
ПАРЫ виноват ровно он: собирать через такой узел эту площадку нельзя.
Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный
боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить
дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре.
Защита последнего узла и эскалация срока целиком из `mark_banned`, здесь ничего
своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не
пишется (голодание хуже работы через плохой узел).
Returns: "ok" | "cleared" (сняли свой бан) | "banned" | "ignored".
"""
if ok:
cleared = clear_source_bans(
db,
proxy_id,
source=source,
reason=f"browser probe OK for source={source} ({detail})",
only_reason=_PROBE_BAN_REASON,
)
return "cleared" if cleared else "ok"
if fail_kind not in ("proxy", "page"):
logger.warning(
"proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре "
"(fail_kind=%s): %s — вердикт не пишем",
proxy_id,
source,
fail_kind,
detail,
)
return "ignored"
logger.warning(
"proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан "
"пары, узел остаётся первосортным для остальных площадок (#2800)",
proxy_id,
source,
fail_kind,
detail,
)
mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON)
return "banned"
def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int:
"""Освободить lease'ы старше older_than_minutes (упавший sweep не вызвал release).
@ -976,8 +1108,39 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
return False, None, None, "other"
async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) -> str:
"""Одна браузерная проба узла + запись вердикта. Returns исход mark_browser_health.
def _probe_sources_for(affinity: str) -> list[str]:
"""Источники, которым узел с такой affinity МОЖЕТ достаться (#2800).
Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`.
Спрашивать площадки, которым узел всё равно не выдадут, платить за диагностику,
которой никто не воспользуется.
ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих
свободных нет) такая пара останется без вердикта и решится как раньше, по факту
прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же
только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос
бы зря. Если fallback станет частым снять условие, цена известна: N_узлов × 4.
"""
from scraper_kit.browser_fetcher import PROBE_SOURCES
return [s for s in PROBE_SOURCES if affinity in (s, "any")]
async def _run_pair_probes(
db: Session, proxy_id: int, url: str, kind: str, affinity: str
) -> tuple[str, dict[str, int]]:
"""Крест «этот узел × каждая его площадка» + запись вердиктов (#2800).
Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам).
Два уровня вердикта, и они не пересекаются:
- ПАРА (`mark_source_probe` scrape_proxy_source_bans) по каждой площадке
отдельно, это то, что читает `acquire(source)`;
- УЗЕЛ (`mark_browser_health` browser_fail_streak/browser_unfit_since) по
итогу ВСЕГО креста: хоть одна площадка ответила браузерный тракт через узел
работает (ok); все отказали транспортом отказ узла. Отказ ОДНОЙ площадки
узел глобально не пятнает иначе на месте вылеченного схлопывания диагнозов
появилось бы новое.
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
healthcheck ipify-часть уже отработала и её результат записан. Диагностика не
@ -985,18 +1148,60 @@ async def _run_browser_probe(db: Session, proxy_id: int, url: str, kind: str) ->
"""
from scraper_kit.browser_fetcher import probe_proxy_via_browser
try:
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind
counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0}
fail_kinds: list[str] = []
any_ok = False
last_detail = ""
for source in _probe_sources_for(affinity):
try:
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
if not ok and fail_kind == "proxy":
# Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает
# флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор
# идёт по уже поднятому браузеру с тем же прокси — секунды, и только
# на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью
# (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания.
ok, fail_kind, detail = await probe_proxy_via_browser(
_settings.browser_http_endpoint, url, proxy_kind=kind, source=source
)
except Exception:
logger.warning(
"proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан",
proxy_id,
source,
exc_info=True,
)
continue
counters["pair_checked"] += 1
last_detail = detail
if ok:
any_ok = True
else:
fail_kinds.append(fail_kind or "other")
outcome = mark_source_probe(
db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail
)
except Exception:
logger.warning(
"proxy_pool: browser probe crashed for proxy id=%d — вердикт не записан",
proxy_id,
exc_info=True,
)
return "ignored"
return mark_browser_health(db, proxy_id, ok, fail_kind=fail_kind, detail=detail)
if outcome == "banned":
counters["pair_banned"] += 1
elif outcome == "cleared":
counters["pair_cleared"] += 1
if counters["pair_checked"] == 0:
return "ignored", counters # крест не состоялся — узел не судим
if any_ok:
return mark_browser_health(db, proxy_id, True, detail=last_detail), counters
# Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ —
# транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле».
node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0]
return (
mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail),
counters,
)
def _mask(url: str) -> str:
@ -1032,18 +1237,20 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
В конце purge бан-строк (#2600 п.2), истёкших дольше SOURCE_BAN_PURGE_DAYS назад
(см. комментарий у самого DELETE: отложенность это и есть сброс ban_count).
БРАУЗЕРНАЯ ПРОБА (#2723): узлам, прошедшим ipify и не проверявшимся браузером
дольше BROWSER_PROBE_MINUTES, дополнительно гоняется проба ЧЕРЕЗ САЙДКАР (тот же
тракт, что у боевого сбора: camoufox стартует с этим прокси, потом навигация на
robots.txt площадки). Её вердикт идёт в ОТДЕЛЬНЫЕ поля (mark_browser_health) и
никогда не смешивается с consecutive_fails/enabled. Гейт settings.
use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и проба
измеряла бы то, чем никто не пользуется.
БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не
проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ
САЙДКАР по одной навигации на каждую площадку, которую этот узел может
обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси,
потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в
scrape_proxy_source_bans (его читает acquire(source)), вердикт узла в отдельные
browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт
settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и
проба измеряла бы то, чем никто не пользуется.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
browser_unfit, browser_refit}.
browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}.
"""
reaped = reap_stale_leases(db)
@ -1051,7 +1258,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
SELECT id, url, kind, enabled, disabled_reason,
SELECT id, url, kind, enabled, disabled_reason, provider_affinity,
(browser_check_at IS NULL
OR browser_check_at < now() - make_interval(
mins => CAST(:browser_probe_minutes AS integer)
@ -1082,6 +1289,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
browser_ok = 0
browser_unfit = 0
browser_refit = 0
pair_checked = 0
pair_banned = 0
pair_cleared = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
@ -1111,8 +1321,13 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
# не тот узел», из-за которого #2723 и появилась.
if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser:
outcome = await _run_browser_probe(db, proxy_id, url, str(row["kind"]))
outcome, pair_counters = await _run_pair_probes(
db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"])
)
browser_checked += 1
pair_checked += pair_counters["pair_checked"]
pair_banned += pair_counters["pair_banned"]
pair_cleared += pair_counters["pair_cleared"]
if outcome in ("ok", "refit"):
browser_ok += 1
if outcome == "refit":
@ -1142,7 +1357,8 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
logger.info(
"proxy_pool: healthcheck done — reaped=%d checked=%d ok=%d failed=%d revived=%d "
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d "
"pair_checked=%d pair_banned=%d pair_cleared=%d",
reaped,
checked,
ok_count,
@ -1153,6 +1369,9 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
browser_ok,
browser_unfit,
browser_refit,
pair_checked,
pair_banned,
pair_cleared,
)
return {
"reaped": reaped,
@ -1167,4 +1386,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"browser_ok": browser_ok,
"browser_unfit": browser_unfit,
"browser_refit": browser_refit,
# Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и
# pair_banned=2 одновременно — это не противоречие, а точный диагноз
# «браузер через узел работает, но две площадки его не пускают».
"pair_checked": pair_checked,
"pair_banned": pair_banned,
"pair_cleared": pair_cleared,
}

View file

@ -397,12 +397,18 @@ class FakeSession:
)
if "DELETE FROM scrape_proxy_source_bans" in sql and "proxy_id = CAST" in sql:
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2
# clear_source_bans: снять баны узла (все либо один source), #2600 п.2.
# Фильтр по reason (#2800) гейтим по подстроке боевого SQL — как ban-фильтры
# в acquire-ветке: иначе мок «чинил» бы код, который фильтра не содержит, и
# тест на «успешная проба не гасит чужой бан» остался бы зелёным на сломанном.
filters_reason = "reason = CAST(:only_reason AS text)" in sql
only_reason = p.get("only_reason") if filters_reason else None
cleared = [
b
for b in self.bans
if b["proxy_id"] == p["proxy_id"]
and (p["source"] is None or b["source"] == p["source"])
and (only_reason is None or b.get("reason") == only_reason)
]
self.bans = [b for b in self.bans if b not in cleared]
return _FakeResult([{"source": b["source"]} for b in cleared])

View file

@ -260,27 +260,35 @@ async def test_healthcheck_marks_unfit_when_http_green_browser_red(
row = db._by_id(1)
assert row["browser_unfit_since"] is not None
assert row["enabled"] is True and row["consecutive_fails"] == 0
assert len(calls) == 2
# За такт узел опрашивается по КАЖДОЙ обслуживаемой площадке (#2800), а каждый
# транспортный отказ ещё и подтверждается повтором: 4 источника × 2 попытки за
# прогон. Узловой вердикт по-прежнему один на прогон — browser_checked == 1 выше.
assert len(calls) == 2 * 4 * 2
async def test_healthcheck_browser_probe_respects_slow_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет.
Крест по площадкам (#2800) такт НЕ участил: он умножил цену ОДНОГО прогона на
число обслуживаемых источников (здесь 4), а прогонов с браузерной пробой
по-прежнему один на BROWSER_PROBE_MINUTES.
"""
calls: list[str] = []
_patch_probes(monkeypatch, calls=calls)
db = FakeSession([_proxy(1)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 1
assert len(calls) == 4
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 1, "браузерная проба обязана идти реже ipify — она стоит camoufox"
assert len(calls) == 4, "браузерная проба обязана идти реже ipify — она стоит camoufox"
db._by_id(1)["browser_check_at"] = datetime.now(UTC) - timedelta(
minutes=BROWSER_PROBE_MINUTES + 1
)
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 2
assert len(calls) == 8
async def test_healthcheck_skips_browser_probe_when_http_dead(

View file

@ -0,0 +1,382 @@
"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ.
Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь —
верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный
ответ означал «узел годен для Авито», а читался как «узел годен вообще».
Замер на проде 09.08.2026 (тот же тракт: сайдкар camoufox с этим прокси навигация):
узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru
-----+----------+-------+---------------------+--------------+---------------------------
1 | domclick | 200 | 200 «Ошибка Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY
10 | any | 200 | 200 | 200 | 200
Что сторожится (каждый тест ниже падает на коде до фикса):
1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для
Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')`
выдавал его как ни в чём не бывало.
2. Адрес пробы рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен
(`domclick.ru` через тот же узел отвечает 200 проба по нему была бы зелёной).
3. HTTP 200 с заглушкой вместо robots.txt это отказ пары, а не успех.
4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и
забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что
«дешёвая ipify стирает браузерный вердикт» в #2723).
5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ пятнает (узловой
вердикт #2723 сохранён).
6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс).
7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
import scraper_kit.browser_fetcher as bf
from app.services import proxy_pool
from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release
from tests.services.test_proxy_pool import FakeSession, _proxy
# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным.
_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = {
("http://u:p@h1:8080", "domclick"): (
False,
"proxy",
'{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}',
),
}
def _patch_probes(
monkeypatch: pytest.MonkeyPatch,
matrix: dict[tuple[str, str], tuple[bool, str | None, str]],
calls: list[tuple[str, str]] | None = None,
*,
default: tuple[bool, str | None, str] = (True, None, "html_len=16477"),
) -> None:
"""ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник)."""
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
return True, "1.2.3.4", 10, None
async def _fake_browser(
endpoint: str, proxy_url: str, **kw: Any
) -> tuple[bool, str | None, str]:
source = str(kw.get("source", "avito"))
if calls is not None:
calls.append((proxy_url, source))
return matrix.get((proxy_url, source), default)
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
# ── 1. главное: вердикт разведён по источникам ───────────────────────────────
async def test_node_dead_for_domclick_is_not_issued_to_domclick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём.
Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при
`browser_fail_streak=0` и свежей пробе.
"""
_patch_probes(monkeypatch, _LIVE_MATRIX)
db = FakeSession([_proxy(1), _proxy(2)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
# Домклику достаётся только исправный узел…
first = acquire(db, "domclick") # type: ignore[arg-type]
assert first is not None and first.id == 2
# …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить
# заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой
# заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0.
assert acquire(db, "domclick") is None, ( # type: ignore[arg-type]
"Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY"
)
# Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла.
lease = acquire(db, "avito") # type: ignore[arg-type]
assert lease is not None and lease.id == 1
ban = db._ban(1, "domclick")
assert ban is not None
assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба"
assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None
assert counters["pair_banned"] == 1
async def test_one_dead_platform_does_not_stain_the_node_globally(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает."""
_patch_probes(monkeypatch, _LIVE_MATRIX)
db = FakeSession([_proxy(1), _proxy(2)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
row = db._by_id(1)
assert row["browser_unfit_since"] is None
assert row["browser_fail_streak"] == 0
assert row["enabled"] is True and row["consecutive_fails"] == 0
async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None:
"""Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел)."""
dead_everywhere = {
("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable")
for src in ("avito", "cian", "yandex", "domclick")
}
_patch_probes(monkeypatch, dead_everywhere)
db = FakeSession([_proxy(1), _proxy(2)])
for _ in range(BROWSER_UNFIT_THRESHOLD):
db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert db._by_id(1)["browser_unfit_since"] is not None
assert db._by_id(2)["browser_unfit_since"] is None
# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ────────────────────────────────
@pytest.mark.parametrize(
("source", "must_contain"),
[
("avito", "www.avito.ru"),
("cian", "ekb.cian.ru"),
("yandex", "realty.yandex.ru"),
# apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200,
# а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной.
("domclick", "bff-search-web.domclick.ru"),
],
)
async def test_probe_asks_the_working_host_of_each_source(
monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str
) -> None:
seen: dict[str, Any] = {}
class _Resp:
status_code = 200
text = '{"html": "<pre>User-agent: *</pre>"}'
@staticmethod
def json() -> dict[str, str]:
return {"html": "<pre>User-agent: *</pre>"}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
seen["payload"] = json
return _Resp()
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
ok, _fail_kind, _detail = await bf.probe_proxy_via_browser(
"http://tradein-browser:3000", "http://u:p@node:8080", source=source
)
assert ok is True
assert must_contain in seen["payload"]["url"]
assert seen["payload"]["url"].endswith("/robots.txt") # нагрузки на площадку нет
# Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии.
assert seen["payload"]["source"] == "generic"
async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы."""
stub = "<!DOCTYPE html><html><head><title>Ошибка - Циан</title></head><body>…</body></html>"
class _Resp:
status_code = 200
text = "{}"
@staticmethod
def json() -> dict[str, str]:
return {"html": stub}
class _Client:
def __init__(self, **_kw: Any) -> None: ...
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_: object) -> None:
return None
async def post(self, url: str, json: dict[str, Any]) -> _Resp:
return _Resp()
monkeypatch.setattr(bf.httpx, "AsyncClient", _Client)
ok, fail_kind, detail = await bf.probe_proxy_via_browser(
"http://tradein-browser:3000", "http://u:p@node:8080", source="cian"
)
assert ok is False
# Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP.
assert fail_kind == "page"
assert "not robots.txt" in detail
async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
"""«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя."""
_patch_probes(
monkeypatch,
{("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")},
)
db = FakeSession([_proxy(1), _proxy(2)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert db._ban(1, "cian") is not None
assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём
# ── 4. проба снимает только свою строку ──────────────────────────────────────
async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None:
"""Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором.
robots.txt площадка отдаёт и забаненному IP разрешить дешёвому успеху гасить
дорогой вердикт значило бы повторить #2723 на паре.
"""
later = datetime.now(UTC) + timedelta(hours=6)
db = FakeSession(
[_proxy(1), _proxy(2)],
bans=[
{
"proxy_id": 1,
"source": "avito",
"banned_until": later,
"ban_count": 1,
"reason": "banned:avito", # распознан боевым сбором (капча/QRATOR)
},
{
"proxy_id": 1,
"source": "cian",
"banned_until": later,
"ban_count": 1,
"reason": "probe:browser", # прошлый вердикт самой пробы
},
],
)
_patch_probes(monkeypatch, {}) # все площадки отвечают
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert db._ban(1, "avito") is not None, "чужой бан проба снимать не имеет права"
assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять"
assert counters["pair_cleared"] == 1
# ── 5-6. чужие отказы ────────────────────────────────────────────────────────
async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None:
"""Лежащий сайдкар не должен забанить разом все пары (#2686-класс)."""
down = {
(f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError")
for pid in (1, 2)
for src in ("avito", "cian", "yandex", "domclick")
}
_patch_probes(monkeypatch, down)
db = FakeSession([_proxy(1), _proxy(2)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["pair_banned"] == 0
assert db.bans == []
assert db._by_id(1)["browser_unfit_since"] is None
# ── 7. цена такта ────────────────────────────────────────────────────────────
async def test_probe_cost_is_nodes_times_servable_sources(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Крест ограничен источниками, которым узел вообще может достаться.
Диагностика не должна превращаться в нагрузку: узел с выделенной affinity
спрашивает ОДНУ площадку, 'any' все четыре. На проде это 4 узла × 4 источника
= 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на
площадку в сутки против ~1000 боевых /fetch.
"""
calls: list[tuple[str, str]] = []
_patch_probes(monkeypatch, {}, calls)
db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [
"avito",
"cian",
"domclick",
"yandex",
]
assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"]
assert len(calls) == 5
async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None:
"""Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки.
Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из
теста выше не меняется, пока всё зелено.
"""
calls: list[tuple[str, str]] = []
_patch_probes(monkeypatch, _LIVE_MATRIX, calls)
db = FakeSession([_proxy(1), _proxy(2)])
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert calls.count(("http://u:p@h1:8080", "domclick")) == 2
assert calls.count(("http://u:p@h1:8080", "avito")) == 1
async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None:
"""Один провал, второй заход зелёный → бан пары не пишется."""
attempts: dict[str, int] = {}
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
return True, "1.2.3.4", 10, None
async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]:
source = str(kw.get("source", "avito"))
key = f"{proxy_url}|{source}"
attempts[key] = attempts.get(key, 0) + 1
if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1:
return False, "proxy", "503 browser unavailable"
return True, None, "html_len=150"
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky)
db = FakeSession([_proxy(1), _proxy(2)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["pair_banned"] == 0
assert db._ban(1, "domclick") is None
lease = acquire(db, "domclick") # type: ignore[arg-type]
assert lease is not None
release(db, lease.id) # type: ignore[arg-type]

View file

@ -37,22 +37,50 @@ _RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
# Адрес пробы. Требования к нему ровно три, и robots.txt им отвечает:
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
# и маршрут до её edge, а не до нейтрального хоста;
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
# 2) та же площадка, что реально отказывает: TLS-рукопожатие и маршрут до ЕЁ edge,
# а не до нейтрального хоста;
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл, который
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
# Такт пробы редкий (proxy_pool.BROWSER_PROBE_MINUTES) — при 4 узлах это ~16
# запросов в сутки против ~1000 боевых /fetch (замер на проде 06.08).
_PROXY_PROBE_URL: str = "https://www.avito.ru/robots.txt"
# source='generic' НАМЕРЕННО, хотя адрес авитовский: сайдкар держит по инстансу
# camoufox на провайдера с отдельным локом, и проба с source='avito' забирала бы лок
# боевого инстанса и релончила его (прокси пробы ≠ прокси сессии) — ровно тот
# relaunch-шторм, который лечил sticky-lease фикс. 'generic' — свой инстанс, боевые
# развёртки его не используют.
#
# АДРЕС НА КАЖДЫЙ ИСТОЧНИК, а не один зашитый (#2800). До этого проба всех узлов
# ходила на Авито, и её зелёный ответ читался как «узел годен», хотя означал
# «годен для Авито». Замер на проде 09.08.2026 показал ровно ту цену: узел id=1
# отдавал 200 на Авито/Яндексе и 500 NS_ERROR_PROXY_BAD_GATEWAY на Домклике, имея
# при этом browser_fail_streak=0 и свежую пробу.
#
# ХОСТ — РАБОЧИЙ, А НЕ APEX-ДОМЕН. Тот же замер: через узел id=1
# `domclick.ru/robots.txt` отдаёт 200, а `bff-search-web.domclick.ru/robots.txt` —
# 500. Боевой сбор Домклика ходит именно на bff (providers/domclick/serp.py::
# _BFF_BASE), поэтому проба по apex была бы зелёной и бесполезной — та же ошибка
# «проба идёт не рабочим путём», что и #2723, на третьей оси.
_PROBE_URLS: dict[str, str] = {
"avito": "https://www.avito.ru/robots.txt",
"cian": "https://ekb.cian.ru/robots.txt", # рабочий хост — providers/cian/serp.py base_url
"yandex": "https://realty.yandex.ru/robots.txt", # providers/yandex/serp.py::_GATE_URL
"domclick": "https://bff-search-web.domclick.ru/robots.txt",
}
_PROXY_PROBE_URL: str = _PROBE_URLS["avito"]
# Источники, по которым вообще есть что спрашивать. Публичный кортеж — proxy_pool
# перебирает его, чтобы не заводить второй список площадок на стороне backend'а.
PROBE_SOURCES: tuple[str, ...] = tuple(_PROBE_URLS)
# HTTP 200 + непустой HTML ещё не значит «площадка ответила»: замер 09.08 — Циан
# через узел id=1 отдаёт 200 и 374 КБ HTML со страницей «Ошибка — Циан» вместо
# robots.txt. Такой ответ проба до #2800 засчитывала как успех. Маркер ниже есть в
# robots.txt всех четырёх рабочих хостов (проверено вживую) и отсутствует в
# странице-заглушке — самый дешёвый способ отличить «отдали ресурс» от «отдали
# отказ с кодом 200».
_PROBE_CONTENT_MARKER: str = "User-agent"
# source='generic' В ТЕЛЕ /fetch НАМЕРЕННО, какой бы площадке ни принадлежал адрес:
# сайдкар держит по инстансу camoufox на провайдера с отдельным локом, и проба с
# source='avito' забирала бы лок боевого инстанса и релончила его (прокси пробы ≠
# прокси сессии) — ровно тот relaunch-шторм, который лечил sticky-lease фикс.
# 'generic' — свой инстанс, боевые развёртки его не используют. Аргумент `source` у
# probe_proxy_via_browser выбирает АДРЕС (какую площадку спрашиваем), а не инстанс.
_PROXY_PROBE_SOURCE: str = "generic"
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
# замерено на проде вместе с релончем, плюс запас на медленный узел.
@ -126,8 +154,10 @@ def classify_browser_probe(status: int | None, detail: str) -> str:
browser_fail_streak.
- "sidecar" сайдкар недоступен/не сконфигурирован (connect error, таймаут,
503 «no proxy configured», прочие 5xx). Узел не виноват.
- "page" тракт сработал, но ответ не похож на страницу (пустое тело).
Узел не виноват; повод посмотреть на площадку, не на пул.
- "page" тракт сработал, но ответ не похож на запрошенный ресурс (пустое
тело либо 200 со страницей-заглушкой вместо robots.txt, #2800).
Браузерный тракт узла исправен не годится ПАРА «узел ×
площадка»: этому exit-IP площадка ресурс не отдала.
"""
if status is None:
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
@ -143,10 +173,11 @@ async def probe_proxy_via_browser(
proxy_url: str,
*,
proxy_kind: str = "http",
url: str = _PROXY_PROBE_URL,
source: str = "avito",
url: str | None = None,
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
) -> tuple[bool, str | None, str]:
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
"""Проверить ПАРУ «узел × площадка» тем же трактом, что и работа: сайдкар → camoufox → прокси.
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
вредна health-checker проверяет узлы, в том числе арендованные, и не должен
@ -156,13 +187,18 @@ async def probe_proxy_via_browser(
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки это уже заметная
нагрузка на неё, ради которой проба и затевалась бы наоборот.
`source` выбирает АДРЕС пробы (`_PROBE_URLS`, #2800) — рабочий хост именно этой
площадки. Прежняя сигнатура спрашивала только Авито, и её зелёный ответ означал
«узел годен для Авито», а читался как «узел годен». `url` (явный адрес) остаётся
для тестов и разовых проверок и перекрывает `source`.
Returns:
(ok, fail_kind, detail). ok=True fail_kind=None. Иначе fail_kind
"proxy" / "sidecar" / "page" (см. classify_browser_probe), detail
обрезанный текст для лога.
"""
payload: dict[str, object] = {
"url": url,
"url": url or _PROBE_URLS.get(source, _PROXY_PROBE_URL),
"source": _PROXY_PROBE_SOURCE,
"proxy": proxy_url,
"proxy_kind": proxy_kind,
@ -184,6 +220,15 @@ async def probe_proxy_via_browser(
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
if _PROBE_CONTENT_MARKER not in html:
# 200 и непустое тело, но это не robots.txt — площадка отдала заглушку
# ЭТОМУ exit-IP (замер 09.08: Циан через узел id=1 → 374 КБ «Ошибка — Циан»).
# Тракт узла исправен, негодна пара — отсюда "page", а не "proxy".
return (
False,
"page",
f"not robots.txt (html_len={len(html)}): {' '.join(html.split())[:120]}",
)
return True, None, f"html_len={len(html)}"