fix(tradein/proxy): проба узла ходит браузерным трактом, вердикт живёт отдельно от HTTP-пробы (#2723)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m5s

ipify-проба отвечает на «узел жив вообще», а сбор Авито с 02.08 (#2637) ходит через
сайдкар браузером: camoufox стартует С ЭТИМ прокси, потом навигация. Это разные
свойства узла, и до сих пор они писались в ОДИН счётчик: боевой /fetch репортил
mark_health(ok=False), а следующая (≤30 мин) успешная ipify-проба делала
consecutive_fails=0 + enabled=true. Дешёвая проба стирала вердикт дорогого тракта —
узел, мёртвый для браузера, выходил из карантина каждые полчаса и снова забирал прогон.

Разведено тем же приёмом, что #2711 (scrape_runs.ban_kind) — поле РЯДОМ, а не новое
значение существующего флага. Миграция 228: browser_fail_streak / browser_unfit_since /
browser_check_at. Успешная ipify их не трогает, провал браузерной пробы не трогает
enabled/consecutive_fails.

Проба: POST /fetch (одна навигация) на robots.txt Авито через сайдкар с прокси узла в
теле. /fetch, а не /fetch-json — последний сначала грузит главную площадки. source=
'generic', чтобы не забирать лок боевого инстанса и не релончить его.

Такт решён замером на проде: одна браузерная проба = 8.3с и один запуск camoufox,
боевая нагрузка сайдкара = ~1000 /fetch и ~190 запусков camoufox в сутки. Каждым
прогоном healthcheck по 4 узлам это +192 запуска (удвоение), поэтому такт 360 мин:
+16 запусков (+8%). Неподтверждённый провал такт не двигает — второе подтверждение
приходит на следующем прогоне (~30 мин), не через 6 часов.

Пометка НЕ выводит узел из пула (пул 4 узла, #2638): acquire отдаёт его последним
(ORDER BY) и громко логирует, если пригодных не осталось. Путь обратно — успешная
браузерная проба (browser_refit). Отказ, принадлежащий сайдкару или площадке, узлу не
засчитывается — иначе одна упавшая общая зависимость пометила бы весь пул (#2686).

Refs #2723
This commit is contained in:
bot-backend 2026-08-06 17:43:42 +05:00
parent 2a1577738a
commit 9803708109
5 changed files with 844 additions and 11 deletions

View file

@ -77,6 +77,20 @@ Sticky session lease (browser-путь, живая регрессия 2026-08):
на каждый /fetch, чтобы reap_stale_leases не отобрал прокси у многочасового
прогона.
Два тракта два диагноза (#2723):
- ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет
consecutive_fails / enabled / exit_ip. Такт каждый прогон healthcheck (30 мин).
- браузерная проба (`_run_browser_probe` сайдкар camoufox с ЭТИМ прокси
навигация) отвечает на «через узел работает браузерный тракт» и владеет
browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228).
Такт свой, редкий (BROWSER_PROBE_MINUTES) она стоит запуска camoufox.
Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе
дешёвая проба каждые 30 минут стирает вердикт дорогого тракта узел, мёртвый для
браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел
(он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686.
«Непригоден для браузера» это НЕ исключение из пула: acquire() лишь отдаёт такой
узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже.
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
"""
@ -90,9 +104,13 @@ import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings as _settings
logger = logging.getLogger(__name__)
__all__ = [
"BROWSER_PROBE_MINUTES",
"BROWSER_UNFIT_THRESHOLD",
"DISABLED_RECHECK_MINUTES",
"DISABLE_THRESHOLD",
"MAX_CONSECUTIVE_FAILS",
@ -105,6 +123,7 @@ __all__ = [
"acquire",
"clear_source_bans",
"mark_banned",
"mark_browser_health",
"mark_health",
"reap_stale_leases",
"release",
@ -155,6 +174,27 @@ SOURCE_BAN_PURGE_DAYS = 7
_HEALTH_PROBE_URL = "https://api.ipify.org"
_HEALTH_PROBE_TIMEOUT_S = 10.0
# ── браузерная проба узла (#2723) ────────────────────────────────────────────
# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026):
# - одна браузерная проба = 8.3с и один запуск camoufox;
# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час
# (≈1000 и ≈190 в сутки);
# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки.
# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки,
# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт
# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена,
# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно
# ловится в разы раньше, чем сейчас (не ловится вовсе).
BROWSER_PROBE_MINUTES = 360
# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в
# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция
# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте
# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck
# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале
# намеренно не обновляется (см. mark_browser_health).
BROWSER_UNFIT_THRESHOLD = 2
# deep-review fix 2 (#2600 п.1): фиксированный ключ pg_advisory_xact_lock для
# mark_banned (см. её докстринг). Один произвольный int64 — не завязан ни на что
# в схеме (не id таблицы/строки), выбран как "случайное" число, чтобы не
@ -213,7 +253,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT id, url, kind, rotate_url
SELECT id, url, kind, rotate_url, browser_unfit_since
FROM scrape_proxies
WHERE enabled
AND consecutive_fails < CAST(:max_fails AS integer)
@ -226,7 +266,9 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
AND b.source = :provider
AND b.banned_until > now()
)
ORDER BY last_ok_at NULLS LAST, id
-- browser_unfit последним (#2723): узел, живой для HTTP, но не для
-- браузера, из пула НЕ исключается только уходит в конец очереди.
ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -247,7 +289,7 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
db.execute(
text(
"""
SELECT sp.id, sp.url, sp.kind, sp.rotate_url
SELECT sp.id, sp.url, sp.kind, sp.rotate_url, sp.browser_unfit_since
FROM scrape_proxies AS sp
WHERE sp.enabled
AND sp.consecutive_fails < CAST(:max_fails AS integer)
@ -283,7 +325,8 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
)
)
)
ORDER BY sp.last_ok_at NULLS LAST, sp.id
-- см. ORDER BY основного запроса (#2723)
ORDER BY (sp.browser_unfit_since IS NOT NULL), sp.last_ok_at NULLS LAST, sp.id
FOR UPDATE SKIP LOCKED
LIMIT 1
"""
@ -323,6 +366,19 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe
logger.info(
"proxy_pool: leased proxy id=%d provider=%s by=%s", proxy_id, provider, lease_marker
)
if row["browser_unfit_since"] is not None:
# Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит
# пригодных свободных не осталось. Голодание хуже работы через плохой узел
# (та же политика, что у защиты последнего узла в mark_banned), но молчать об
# этом нельзя: для браузерного источника это заведомо обречённый прогон.
logger.warning(
"proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s "
"(жив для HTTP, браузерный тракт через него не работает). Выдан потому, "
"что пригодных свободных узлов нет — пул надо пополнять (#2638).",
proxy_id,
provider,
row["browser_unfit_since"],
)
return ProxyLease(
id=proxy_id,
url=str(row["url"]),
@ -480,6 +536,145 @@ def mark_health(
)
def mark_browser_health(
db: Session,
proxy_id: int,
ok: bool,
*,
fail_kind: str | None = None,
detail: str = "",
) -> str:
"""Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков.
ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от
«у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того
же. mark_health отвечает на «узел жив вообще» и владеет
consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает
браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/
browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное:
- успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы
(через consecutive_fails=0) узел, мёртвый для браузера, выходил из карантина
каждые 30 минут и снова забирал прогон;
- провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел:
он жив, просто не для этого тракта.
ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см.
scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и
"page" (площадка отдала пустое) узлу не принадлежат засчитывать их значило бы
пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть
повторить #2686 ещё раз и уже с последствиями для всего пула.
ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН
(streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом
провале поле остаётся старым следующий же прогон healthcheck (~30 мин) повторит
пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы
полный BROWSER_PROBE_MINUTES.
Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что
помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored"
(провал не принадлежит узлу).
"""
if ok:
row = (
db.execute(
text(
"""
UPDATE scrape_proxies AS sp
SET browser_fail_streak = 0,
browser_unfit_since = NULL,
browser_check_at = now(),
updated_at = now()
-- prev pre-image строки: RETURNING отдаёт УЖЕ обновлённые
-- значения (browser_unfit_since там всегда NULL), а нам нужно
-- знать, была ли это реанимация непригодного узла.
FROM (
SELECT id, browser_unfit_since
FROM scrape_proxies
WHERE id = CAST(:id AS bigint)
) AS prev
WHERE sp.id = prev.id
RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit
"""
),
{"id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
was_unfit = bool(row["was_unfit"]) if row is not None else False
logger.info(
"proxy_pool: browser probe OK id=%d (%s)%s",
proxy_id,
detail,
" — узел снова пригоден для браузера" if was_unfit else "",
)
return "refit" if was_unfit else "ok"
if fail_kind != "proxy":
logger.warning(
"proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу "
"(fail_kind=%s): %s — browser_fail_streak не трогаем",
proxy_id,
fail_kind,
detail,
)
return "ignored"
row = (
db.execute(
text(
"""
UPDATE scrape_proxies
SET browser_fail_streak = browser_fail_streak + 1,
browser_unfit_since = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
AND browser_unfit_since IS NULL
THEN now() ELSE browser_unfit_since
END,
browser_check_at = CASE
WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer)
THEN now() ELSE browser_check_at
END,
updated_at = now()
WHERE id = CAST(:id AS bigint)
RETURNING browser_fail_streak, browser_unfit_since
"""
),
{"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id},
)
.mappings()
.fetchone()
)
db.commit()
if row is None:
logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id)
return "ignored"
streak = int(row["browser_fail_streak"])
if streak >= BROWSER_UNFIT_THRESHOLD:
logger.warning(
"proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для "
"обычного HTTP, но браузерный тракт через него не работает: %s. Узел "
"ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() "
"теперь отдаёт его последним (#2723).",
proxy_id,
streak,
detail,
)
return "unfit"
logger.warning(
"proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не "
"достигнут — перепроверим на следующем прогоне): %s",
proxy_id,
streak,
BROWSER_UNFIT_THRESHOLD,
detail,
)
return "fail"
def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
"""Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2.
@ -645,8 +840,7 @@ def mark_banned(db: Session, proxy_id: int, *, source: str) -> None:
current = (
db.execute(
text(
"SELECT enabled, disabled_reason FROM scrape_proxies "
"WHERE id = CAST(:id AS bigint)"
"SELECT enabled, disabled_reason FROM scrape_proxies WHERE id = CAST(:id AS bigint)"
),
{"id": proxy_id},
)
@ -775,6 +969,29 @@ 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.
Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет
healthcheck ipify-часть уже отработала и её результат записан. Диагностика не
имеет права ломать то, что диагностирует.
"""
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
)
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)
def _mask(url: str) -> str:
"""Скрыть пароль в proxy-url для логов (scheme://user:***@host)."""
if "@" not in url or "//" not in url:
@ -808,9 +1025,18 @@ 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: при выключенном флаге браузер ходит мимо пула и проба
измеряла бы то, чем никто не пользуется.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
{reaped, checked, ok, failed, revived, bans_purged}.
{reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok,
browser_unfit, browser_refit}.
"""
reaped = reap_stale_leases(db)
@ -818,7 +1044,11 @@ 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,
(browser_check_at IS NULL
OR browser_check_at < now() - make_interval(
mins => CAST(:browser_probe_minutes AS integer)
)) AS browser_probe_due
FROM scrape_proxies
WHERE enabled
OR last_check_at IS NULL
@ -828,7 +1058,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ORDER BY id
"""
),
{"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES},
{
"disabled_recheck_minutes": DISABLED_RECHECK_MINUTES,
"browser_probe_minutes": BROWSER_PROBE_MINUTES,
},
)
.mappings()
.all()
@ -838,6 +1071,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
ok_count = 0
failed = 0
revived = 0
browser_checked = 0
browser_ok = 0
browser_unfit = 0
browser_refit = 0
for row in proxies:
proxy_id = int(row["id"])
url = str(row["url"])
@ -860,6 +1097,22 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
else:
failed += 1
# Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода
# мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая
# трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер
# ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы
# вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет
# не тот узел», из-за которого #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"]))
browser_checked += 1
if outcome in ("ok", "refit"):
browser_ok += 1
if outcome == "refit":
browser_refit += 1
elif outcome == "unfit":
browser_unfit += 1
# Purge ДАВНО истёкших бан-строк (#2600 п.2). Порог — banned_until + SOURCE_BAN_PURGE_DAYS,
# НЕ просто `banned_until < now()`: строка после истечения бана ещё ничего не блокирует
# (acquire фильтрует по banned_until > now()), но хранит ban_count — память об эскалации.
@ -882,13 +1135,17 @@ 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",
"bans_purged=%d browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d",
reaped,
checked,
ok_count,
failed,
revived,
purged,
browser_checked,
browser_ok,
browser_unfit,
browser_refit,
)
return {
"reaped": reaped,
@ -897,4 +1154,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
"failed": failed,
"revived": revived,
"bans_purged": purged,
# Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed:
# схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль.
"browser_checked": browser_checked,
"browser_ok": browser_ok,
"browser_unfit": browser_unfit,
"browser_refit": browser_refit,
}

View file

@ -0,0 +1,66 @@
-- 228_scrape_proxies_browser_health.sql
-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723).
--
-- WHY:
-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор
-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ
-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла:
-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable
-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие).
--
-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит
-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная
-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА
-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в
-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в
-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) —
-- поле РЯДОМ, а не новое значение существующего флага.
--
-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails):
-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что
-- атрибутируются узлу (сайдкар лежит / страница пустая —
-- не считаются, см. proxy_pool._classify_browser_probe).
-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл.
-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для
-- HTTP, непригоден для браузера». acquire() такой узел НЕ
-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а
-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только
-- если свободных пригодных нет.
-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё
-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска
-- camoufox (~8с замерено на проде), ipify — миллисекунды.
--
-- IDEMPOTENCY / SAFETY:
-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную
-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна
-- существующая выборка не меняет результат (все три колонки новые). Повторный
-- прогон — no-op (auto-apply strict на деплое это требует).
--
-- Dependencies: 157_scrape_proxies.sql
BEGIN;
ALTER TABLE scrape_proxies
ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz,
ADD COLUMN IF NOT EXISTS browser_check_at timestamptz;
COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS
'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), '
'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая '
'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной '
'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.';
COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS
'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не '
'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает '
'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему '
'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.';
COMMENT ON COLUMN scrape_proxies.browser_check_at IS
'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): '
'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. '
'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл '
'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.';
COMMIT;

View file

@ -188,9 +188,14 @@ class FakeSession:
and _not_banned(r)
and (not protects_last_node or _has_backup(r))
]
# ORDER BY last_ok_at NULLS LAST, id
# ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id.
# Первый ключ гейтим по подстроке самого SQL (как ban-фильтры выше): иначе
# мок сортировал бы «правильно» независимо от боевого запроса и не отличил
# бы код до #2723 от кода после.
deprioritises_unfit = "browser_unfit_since IS NOT NULL" in sql
cands.sort(
key=lambda r: (
bool(deprioritises_unfit and r.get("browser_unfit_since") is not None),
r["last_ok_at"] is None,
r["last_ok_at"] or datetime.min.replace(tzinfo=UTC),
r["id"],
@ -250,6 +255,14 @@ class FakeSession:
row["enabled"] = True
elif "enabled" in sql:
row["enabled"] = True
# #2723: если боевой mark_health когда-нибудь снова начнёт обнулять
# ещё и браузерный вердикт (как делал до фикса — тот жил в общем
# consecutive_fails), мок обязан это воспроизвести, иначе
# test_ipify_success_does_not_erase_browser_verdict останется зелёным
# на сломанном коде.
if "browser_fail_streak = 0" in sql:
row["browser_fail_streak"] = 0
row["browser_unfit_since"] = None
if "RETURNING disabled_reason" in sql:
return _FakeResult([{"disabled_reason": row.get("disabled_reason")}])
return _FakeResult([])
@ -272,8 +285,54 @@ class FakeSession:
if r["enabled"] or r.get("last_check_at") is None or r["last_check_at"] < cutoff
]
rows = sorted(cands, key=lambda r: r["id"])
# #2723: браузерная проба со своим тактом. Признак считаем, только если
# боевой SQL его реально запрашивает (см. гейты по подстрокам выше).
if "browser_probe_due" in sql:
b_cutoff = datetime.now(UTC) - timedelta(minutes=p["browser_probe_minutes"])
return _FakeResult(
[
dict(
r,
browser_probe_due=(
r.get("browser_check_at") is None
or r["browser_check_at"] < b_cutoff
),
)
for r in rows
]
)
return _FakeResult([dict(r) for r in rows])
if "SET browser_fail_streak = 0" in sql: # mark_browser_health ok (#2723)
row = self._by_id(p["id"])
if row is None:
return _FakeResult([])
was_unfit = row.get("browser_unfit_since") is not None
row["browser_fail_streak"] = 0
row["browser_unfit_since"] = None
row["browser_check_at"] = datetime.now(UTC)
return _FakeResult([{"was_unfit": was_unfit}])
if "browser_fail_streak = browser_fail_streak + 1" in sql: # mark_browser_health fail
row = self._by_id(p["id"])
if row is None:
return _FakeResult([])
row["browser_fail_streak"] = row.get("browser_fail_streak", 0) + 1
if row["browser_fail_streak"] >= p["threshold"]:
if row.get("browser_unfit_since") is None:
row["browser_unfit_since"] = datetime.now(UTC)
# такт двигаем ТОЛЬКО на подтверждённом провале — иначе неподтверждённое
# подозрение ждало бы полный BROWSER_PROBE_MINUTES (#2723)
row["browser_check_at"] = datetime.now(UTC)
return _FakeResult(
[
{
"browser_fail_streak": row["browser_fail_streak"],
"browser_unfit_since": row.get("browser_unfit_since"),
}
]
)
if "pg_advisory_xact_lock" in sql: # deep-review fix 2 (#2600) — mark_banned serialize
self.advisory_lock_calls.append(p["key"])
return _FakeResult([])
@ -384,6 +443,9 @@ def _proxy(
kind: str = "http",
rotate_url: str | None = None,
disabled_reason: str | None = None,
browser_unfit_since: datetime | None = None,
browser_fail_streak: int = 0,
browser_check_at: datetime | None = None,
) -> dict[str, Any]:
return {
"id": pid,
@ -400,6 +462,10 @@ def _proxy(
"last_check_at": last_check_at,
"exit_ip": None,
"latency_ms": None,
# #2723: здоровье браузерного тракта — отдельные поля, миграция 228.
"browser_unfit_since": browser_unfit_since,
"browser_fail_streak": browser_fail_streak,
"browser_check_at": browser_check_at,
}

View file

@ -0,0 +1,329 @@
"""#2723 — проба здоровья прокси ходит тем же трактом, что и работа.
Что сторожится (каждый тест падает на коде origin/main):
1. Классификация отказа браузерной пробы: узлу принадлежит ТОЛЬКО отказ прокси
(503 «browser unavailable», 500 NS_ERROR_PROXY_*). Лежащий сайдкар и пустая
страница не его вина. Без этого одна упавшая общая зависимость пометила бы
непригодными ВСЕ узлы разом #2686 в третий раз.
2. Тракт пробы: POST /fetch (одна навигация) на robots.txt, с прокси узла в теле.
Не /fetch-json (тот сначала грузит ГЛАВНУЮ площадки) и не выдача.
3. Главное: успешная ipify-проба НЕ стирает вердикт браузерного тракта. На коде до
фикса узел, мёртвый для браузера, выходил из карантина каждые 30 минут
(mark_health(ok=True) consecutive_fails=0 + enabled=true) и снова забирал прогон.
4. Два диагноза разведены в обе стороны: провал браузерной пробы НЕ выключает узел
и НЕ трогает consecutive_fails; провал ipify не пишет ничего в browser-поля.
5. Пометка непригодности НЕ выводит узел из пула: acquire() отдаёт его последним,
но при отсутствии пригодных всё равно выдаёт (голодание хуже) пул из 4 узлов.
6. Реанимация: успешная браузерная проба снимает пометку (browser_refit).
7. Такт: браузерная проба идёт реже ipify (BROWSER_PROBE_MINUTES) и только по узлам,
прошедшим ipify иначе на каждый прогон приходился бы запуск camoufox на узел.
"""
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 pathlib import Path
from typing import Any
import pytest
from scraper_kit.browser_fetcher import classify_browser_probe
from app.services import proxy_pool
from app.services.proxy_pool import BROWSER_PROBE_MINUTES, BROWSER_UNFIT_THRESHOLD, acquire
from tests.services.test_proxy_pool import FakeSession, _proxy
# ── 1. классификация отказа ──────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "detail", "expected"),
[
# Ровно тот текст, которым сайдкар отвечал на все 90 записанных обрывов сбора.
(503, '{"error": "browser unavailable (proxy may be down)"}', "proxy"),
(500, '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY ..."}', "proxy"),
(500, '{"error": "Error: Page.goto: NS_ERROR_UNKNOWN_PROXY_HOST"}', "proxy"),
# Сайдкар не сконфигурирован / лежит / отвечает чем-то ещё — узел ни при чём.
(503, '{"error": "no proxy configured — refusing direct connection (prod)"}', "sidecar"),
(502, "bad gateway", "sidecar"),
(None, "ConnectError: [Errno 111] Connection refused", "sidecar"),
# Тракт сработал, но ответ не похож на страницу — вопрос к площадке, не к пулу.
(200, "", "page"),
],
)
def test_classify_browser_probe(status: int | None, detail: str, expected: str) -> None:
assert classify_browser_probe(status, detail) == expected
def test_sidecar_error_literals_still_exist() -> None:
"""Тripwire: классификация опирается на текст отказа сайдкара — сторожим его.
Если browser/server.py переименует сообщение, «proxy» перестанет распознаваться и
непригодный узел молча останется первосортным. Тест падает СРАЗУ, а не через месяц
зелёных проб (ровно тот сценарий, из-за которого задача и появилась).
"""
server_py = Path(__file__).resolve().parents[2] / "browser" / "server.py"
src = server_py.read_text(encoding="utf-8")
assert "browser unavailable (proxy may be down)" in src
# ── 2. тракт пробы ───────────────────────────────────────────────────────────
async def test_probe_goes_through_sidecar_with_node_proxy(monkeypatch: pytest.MonkeyPatch) -> None:
"""Проба = POST /fetch на robots.txt с прокси УЗЛА в теле, а не httpx-GET мимо всех."""
seen: dict[str, Any] = {}
class _Resp:
status_code = 200
text = '{"html": "<html>User-agent: *</html>"}'
@staticmethod
def json() -> dict[str, str]:
return {"html": "<html>User-agent: *</html>"}
class _Client:
def __init__(self, **kw: Any) -> None:
seen["timeout"] = kw.get("timeout")
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["url"] = url
seen["payload"] = json
return _Resp()
import scraper_kit.browser_fetcher as bf
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", proxy_kind="http"
)
assert ok is True
assert fail_kind is None
# тот же сайдкар и тот же эндпоинт, что у боевого сбора
assert seen["url"] == "http://tradein-browser:3000/fetch"
# НЕ /fetch-json: он делает goto на главную площадки — это уже нагрузка на неё
assert not seen["url"].endswith("/fetch-json")
# прокси проверяемого узла уезжает в тело — иначе camoufox пойдёт через env-прокси
# и проба снова будет измерять не тот узел
assert seen["payload"]["proxy"] == "http://u:p@node:8080"
# адрес — robots.txt площадки, не выдача и не карточка
assert seen["payload"]["url"].endswith("/robots.txt")
assert "avito.ru" in seen["payload"]["url"]
# ── 3-4. два диагноза разведены ──────────────────────────────────────────────
def test_ipify_success_does_not_erase_browser_verdict() -> None:
"""ГЛАВНОЕ: успешная ipify-проба не воскрешает узел, мёртвый для браузера.
До #2723 браузерный вердикт жил в consecutive_fails, и mark_health(ok=True)
обнулял его каждые 30 минут вместе с enabled=true.
"""
db = FakeSession([_proxy(1)])
for _ in range(BROWSER_UNFIT_THRESHOLD):
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
row = db._by_id(1)
assert row["browser_unfit_since"] is not None
proxy_pool.mark_health(db, 1, True, exit_ip="1.2.3.4", latency_ms=100)
row = db._by_id(1)
assert row["consecutive_fails"] == 0 # HTTP-диагноз сброшен, как и раньше
assert row["browser_unfit_since"] is not None # а браузерный — НЕТ
assert row["browser_fail_streak"] >= BROWSER_UNFIT_THRESHOLD
def test_browser_failure_does_not_disable_node() -> None:
"""Обратная сторона: провал браузерного тракта не выключает живой узел."""
db = FakeSession([_proxy(1)])
for _ in range(BROWSER_UNFIT_THRESHOLD + 3):
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
row = db._by_id(1)
assert row["enabled"] is True # узел жив для HTTP — из пула не выводим
assert row["consecutive_fails"] == 0 # и транспортный счётчик не трогаем
assert row["browser_unfit_since"] is not None
def test_sidecar_outage_blames_nobody() -> None:
"""Лежащий сайдкар не должен пометить непригодными все узлы разом (#2686-класс)."""
db = FakeSession([_proxy(1), _proxy(2)])
for pid in (1, 2):
for _ in range(BROWSER_UNFIT_THRESHOLD + 1):
outcome = proxy_pool.mark_browser_health(
db, pid, False, fail_kind="sidecar", detail="ConnectError"
)
assert outcome == "ignored"
for pid in (1, 2):
assert db._by_id(pid)["browser_unfit_since"] is None
assert db._by_id(pid)["browser_fail_streak"] == 0
def test_unconfirmed_failure_keeps_check_at_stale() -> None:
"""Первый (неподтверждённый) провал не двигает такт — перепроверка на след. прогоне."""
db = FakeSession([_proxy(1, browser_check_at=None)])
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
assert db._by_id(1)["browser_fail_streak"] == 1
assert db._by_id(1)["browser_check_at"] is None # такт не сдвинут
proxy_pool.mark_browser_health(db, 1, False, fail_kind="proxy", detail="503")
assert db._by_id(1)["browser_unfit_since"] is not None
assert db._by_id(1)["browser_check_at"] is not None # подтверждён → ждём полный такт
# ── 5. пометка не выводит узел из пула ───────────────────────────────────────
def test_unfit_node_is_last_in_queue_but_still_reachable() -> None:
old = datetime.now(UTC) - timedelta(hours=5)
db = FakeSession(
[
# непригодный, но «давно не использованный» → до #2723 выдавался ПЕРВЫМ
_proxy(1, last_ok_at=old, browser_unfit_since=datetime.now(UTC)),
_proxy(2, last_ok_at=datetime.now(UTC)),
]
)
lease = acquire(db, "avito") # type: ignore[arg-type]
assert lease is not None
assert lease.id == 2 # пригодный вперёд, несмотря на ORDER BY last_ok_at
def test_all_unfit_still_yields_a_proxy() -> None:
"""Все узлы непригодны — система НЕ остаётся без прокси (голодание хуже)."""
db = FakeSession(
[
_proxy(1, browser_unfit_since=datetime.now(UTC)),
_proxy(2, browser_unfit_since=datetime.now(UTC)),
]
)
lease = acquire(db, "avito") # type: ignore[arg-type]
assert lease is not None
# ── 6-7. healthcheck: такт, гейт, реанимация ─────────────────────────────────
def _patch_probes(
monkeypatch: pytest.MonkeyPatch,
*,
http_ok: bool = True,
browser: tuple[bool, str | None, str] = (True, None, "html_len=100"),
calls: list[str] | None = None,
) -> None:
async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]:
return (True, "1.2.3.4", 10, None) if http_ok else (False, None, None, "timeout")
async def _fake_browser(
endpoint: str, proxy_url: str, **_kw: Any
) -> tuple[bool, str | None, str]:
if calls is not None:
calls.append(proxy_url)
return browser
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True)
import scraper_kit.browser_fetcher as bf
monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser)
async def test_healthcheck_marks_unfit_when_http_green_browser_red(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Исторический случай целиком: ipify зелёная, браузер красный → диагноз ставится."""
calls: list[str] = []
_patch_probes(
monkeypatch,
http_ok=True,
browser=(False, "proxy", "503 browser unavailable (proxy may be down)"),
calls=calls,
)
db = FakeSession([_proxy(1)])
first = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert first["ok"] == 1 and first["failed"] == 0 # HTTP-проба по-прежнему зелёная
assert first["browser_checked"] == 1
assert db._by_id(1)["browser_unfit_since"] is None # один провал ещё не приговор
second = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert second["browser_unfit"] == 1
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
async def test_healthcheck_browser_probe_respects_slow_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Успешная проба сдвигает такт: следующий прогон healthcheck её не повторяет."""
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
await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert len(calls) == 1, "браузерная проба обязана идти реже 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
async def test_healthcheck_skips_browser_probe_when_http_dead(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Узел, не прошедший ipify, мёртв целиком — жечь на него запуск camoufox незачем."""
calls: list[str] = []
_patch_probes(monkeypatch, http_ok=False, calls=calls)
db = FakeSession([_proxy(1)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["failed"] == 1
assert counters["browser_checked"] == 0
assert calls == []
async def test_healthcheck_skips_browser_probe_when_pool_not_wired(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Флаг выключен → браузер ходит мимо пула, вердикт об узлах пула бессмыслен."""
calls: list[str] = []
_patch_probes(monkeypatch, calls=calls)
monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", False)
db = FakeSession([_proxy(1)])
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["browser_checked"] == 0
assert calls == []
async def test_healthcheck_revives_unfit_node(monkeypatch: pytest.MonkeyPatch) -> None:
"""Путь обратно: успешная браузерная проба снимает пометку непригодности."""
_patch_probes(monkeypatch)
db = FakeSession(
[
_proxy(
1,
browser_unfit_since=datetime.now(UTC) - timedelta(days=1),
browser_fail_streak=4,
browser_check_at=datetime.now(UTC) - timedelta(minutes=BROWSER_PROBE_MINUTES + 1),
)
]
)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["browser_refit"] == 1
row = db._by_id(1)
assert row["browser_unfit_since"] is None
assert row["browser_fail_streak"] == 0

View file

@ -36,6 +36,42 @@ logger = logging.getLogger(__name__)
_RETRY_SLEEP_S: float = 1.0
_HTTP_TIMEOUT_S: float = 120.0 # навигация медленная → щедрый таймаут
# ── проба узла ПО БРАУЗЕРНОМУ ТРАКТУ (#2723) ─────────────────────────────────
# Адрес пробы. Требования к нему ровно три, и robots.txt Авито им отвечает:
# 1) тот же тракт, что у работы — сайдкар, camoufox, ЭТОТ прокси, настоящая
# навигация. Все 90 записанных обрывов сбора («browser unavailable (proxy may
# be down)») рождались на launch'е camoufox с прокси — проба обязана его делать;
# 2) та же площадка, что реально отказывает (100% обрывов — avito): TLS-рукопожатие
# и маршрут до её edge, а не до нейтрального хоста;
# 3) НУЛЕВАЯ нагрузка на площадку: robots.txt — статический файл ~4КБ, который
# автоматическим клиентам читать прямо предписано. НЕ выдача и НЕ карточка.
# Такт пробы редкий (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' — свой инстанс, боевые
# развёртки его не используют.
_PROXY_PROBE_SOURCE: str = "generic"
# Щедрее ipify-пробы (10с) на порядок: сюда входит холодный запуск camoufox — 8.3с
# замерено на проде вместе с релончем, плюс запас на медленный узел.
_PROXY_PROBE_TIMEOUT_S: float = 90.0
# Маркеры отказов, которые сайдкар порождает ИМЕННО из-за прокси (browser/server.py:
# fetch_handler 503 после _ensure_browser → camoufox не поднялся с этим прокси;
# 500 с NS_ERROR_PROXY_* → навигация не прошла через прокси). Всё остальное —
# не про узел (сайдкар недоступен, конфиг сайдкара, пустая страница).
# ponytail: подстроки, а не машинный код отказа — сайдкар не отдаёт поле причины.
# Тест test_2723_browser_probe.py::test_sidecar_error_literals_still_exist сторожит
# расхождение с исходником сайдкара; при следующей правке browser/server.py дешевле
# добавить туда {"fail_kind": "proxy"} и читать его здесь.
_PROXY_FAIL_MARKERS: tuple[str, ...] = (
"browser unavailable (proxy may be down)",
"NS_ERROR_PROXY",
"NS_ERROR_UNKNOWN_PROXY_HOST",
)
# Живая регрессия 2026-08: после скольких подряд провалившихся /fetch ТЕКУЩИЙ session-lease
# считается плохим (бан/сетевая труха) и ОСОЗНАННО меняется один раз (release+acquire), вместо
# того чтобы менять прокси на каждый /fetch как раньше. Camoufox релончится ТОЛЬКО при реальной
@ -78,6 +114,79 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None:
) from exc
def classify_browser_probe(status: int | None, detail: str) -> str:
"""Кому принадлежит отказ браузерной пробы: узлу, сайдкару или странице (#2723).
Разведение обязательно, иначе повторяется #2686 в третий раз: лежащий сайдкар
пометил бы НЕПРИГОДНЫМИ ВСЕ узлы разом, хотя ни один из них не при чём.
- "proxy" отказ порождён прокси: camoufox не поднялся с ним (503 «browser
unavailable (proxy may be down)») либо навигация не прошла через
него (500 NS_ERROR_PROXY_*). ТОЛЬКО этот исход копит
browser_fail_streak.
- "sidecar" сайдкар недоступен/не сконфигурирован (connect error, таймаут,
503 «no proxy configured», прочие 5xx). Узел не виноват.
- "page" тракт сработал, но ответ не похож на страницу (пустое тело).
Узел не виноват; повод посмотреть на площадку, не на пул.
"""
if status is None:
return "sidecar" # до ответа не дошло — сайдкар/сеть контейнера
if any(marker in detail for marker in _PROXY_FAIL_MARKERS):
return "proxy"
if status >= 400:
return "sidecar"
return "page"
async def probe_proxy_via_browser(
endpoint: str,
proxy_url: str,
*,
proxy_kind: str = "http",
url: str = _PROXY_PROBE_URL,
timeout_s: float = _PROXY_PROBE_TIMEOUT_S,
) -> tuple[bool, str | None, str]:
"""Проверить узел ТЕМ ЖЕ трактом, которым идёт работа: сайдкар → camoufox → прокси.
Standalone (не метод `BrowserFetcher`) и БЕЗ пула: аренда узла здесь не нужна и
вредна health-checker проверяет узлы, в том числе арендованные, и не должен
конкурировать за lease с боевым прогоном.
Используется `/fetch` (одна навигация), а НЕ `/fetch-json`: последний сначала
делает goto на origin, т.е. на ГЛАВНУЮ страницу площадки это уже заметная
нагрузка на неё, ради которой проба и затевалась бы наоборот.
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,
"source": _PROXY_PROBE_SOURCE,
"proxy": proxy_url,
"proxy_kind": proxy_kind,
}
try:
async with httpx.AsyncClient(timeout=timeout_s) as client:
resp = await client.post(f"{endpoint}/fetch", json=payload)
except Exception as exc:
detail = f"{type(exc).__name__}: {str(exc)[:200]}"
return False, classify_browser_probe(None, detail), detail
detail = " ".join((resp.text or "").split())[:300]
if resp.status_code != 200:
return False, classify_browser_probe(resp.status_code, detail), detail
try:
html = resp.json().get("html") or ""
except Exception:
html = ""
if not html:
return False, classify_browser_probe(resp.status_code, detail), "empty html"
return True, None, f"html_len={len(html)}"
class BrowserFetcher:
"""Async context manager: HTTP-клиент к tradein-browser HTTP-сервису.