fixup(browser): source-fallback routing, lazy launch, test stability (#1793 review)
All checks were successful
CI / changes (pull_request) Successful in 5s
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

This commit is contained in:
bot-backend 2026-06-19 19:00:10 +03:00
parent 77ae4ca779
commit 1578dd5a5d
2 changed files with 55 additions and 23 deletions

View file

@ -17,7 +17,7 @@ WS-сервер) несовместим с современными версия
Per-provider модель (#1793):
Вместо одного глобального браузера + одного egress-прокси держим ОТДЕЛЬНЫЙ
инстанс camoufox на каждого поставщика (avito/cian/yandex/generic). Поставщик
определяется по host URL запроса (или явному полю body["provider"]). Каждый
определяется по host URL запроса (или явному полю body["provider"]/["source"]). Каждый
инстанс ходит через свой http-прокси (BROWSER_PROXY_AVITO/CIAN/YANDEX) и имеет
geoip=True фингерпринт привязан к гео конкретного прокси, поэтому смешивать
источники в одном браузере нельзя.
@ -203,6 +203,7 @@ async def _lock_for(provider: str) -> asyncio.Lock:
return existing
assert _locks_guard is not None, "_locks_guard not initialised"
async with _locks_guard:
# setdefault идемпотентен — вторая корутина под guard получит уже созданный лок.
return _locks.setdefault(provider, asyncio.Lock())
@ -304,11 +305,19 @@ async def _ensure_browser(provider: str) -> bool:
Returns:
True если браузер доступен (был или поднялся), False если launch не удался
(прокси лежит) caller отдаёт 503.
При неудачном lazy-launch (прокси лёг) запускает фоновую retry-задачу для
ЭТОГО поставщика: при eager-startup её ставил _on_startup, в lazy-режиме
первый обратившийся /fetch. Поставщики независимы фоновый retry поднимается
только для тех, к кому был запрос, а не для всех сразу.
"""
if _browsers.get(provider) is not None:
return True
await _try_launch_browser(provider)
return _browsers.get(provider) is not None
if _browsers.get(provider) is not None:
return True
_start_retry_task(provider)
return False
async def _browser_retry_loop(provider: str) -> None:
@ -351,28 +360,22 @@ def _start_retry_task(provider: str) -> None:
async def _on_startup(app: web.Application) -> None:
# Startup ТОЛЬКО инициализирует guard для ленивого создания локов — браузеры
# НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на
# первом /fetch (или /login) к нему через _ensure_browser под _locks[provider].
#
# Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM:
# до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS
# каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном
# прокси) запускается при первом обращении к поставщику, не для всех сразу.
global _locks_guard
_locks_guard = asyncio.Lock()
logger.info(
"tradein-browser: per-provider concurrency — параллель между %s, ≤1 на поставщика",
"tradein-browser: per-provider concurrency (lazy launch) — "
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
list(PROVIDERS),
)
# Eager-launch каждого поставщика. Launch НЕ фатален: если прокси лежит,
# camoufox бросит InvalidProxy. Раньше это валило startup → exit(1) →
# restart-loop. Теперь ловим, остаёмся Up и ретраим в фоне. Поставщики
# независимы: лёгший avito-прокси не мешает подняться cian/yandex.
for provider in PROVIDERS:
lock = await _lock_for(provider)
async with lock:
launched = await _try_launch_browser(provider)
if not launched:
logger.warning(
"tradein-browser[%s]: старт без браузера (launch failed) — запуск фонового retry",
provider,
)
_start_retry_task(provider)
async def _on_cleanup(app: web.Application) -> None:
# Отменяем все фоновые retry-задачи.
@ -400,8 +403,17 @@ async def _on_cleanup(app: web.Application) -> None:
def _resolve_provider(body: dict, url: str) -> str:
"""Выбирает поставщика: явный body["provider"] (если валиден) иначе по host URL."""
explicit = body.get("provider")
"""Выбирает поставщика: явный body["provider"]/body["source"] (если валиден) иначе host.
Caller (browser_fetcher.py) шлёт {"url","source"} поэтому explicit-значение
берём из "provider" ИЛИ "source", но применяем ТОЛЬКО если оно в PROVIDERS;
иначе host-detect; иначе generic.
# domclick (source='domklik') не имеет выделенного BROWSER_PROXY_DOMCLICK в prod
# → generic. Намеренно. ('domklik' нет в PROVIDERS → fallthrough на host-detect
# domclick.ru → generic.)
"""
explicit = body.get("provider") or body.get("source")
if isinstance(explicit, str) and explicit in PROVIDERS:
return explicit
return _provider_for_url(url)
@ -420,7 +432,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
"""POST /fetch {"url": "..."} → {"html": "..."}
Выполняет навигацию в браузере поставщика и возвращает полный HTML страницы.
Поставщик определяется по host URL (или явному body["provider"]). Берётся
Поставщик определяется по host URL (или явному body["provider"]/["source"]). Берётся
ТОЛЬКО лок этого поставщика разные поставщики работают параллельно, внутри
поставщика строго 1 fetch.
"""
@ -590,7 +602,7 @@ async def login_handler(request: web.Request) -> web.Response:
Выполняет вход на указанную страницу (email + пароль), ждёт появления
success_cookie, возвращает полный список cookies браузерного контекста.
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL
или явному body["provider"]).
или явному body["provider"]/["source"]).
"""
try:
body = await request.json()

View file

@ -148,6 +148,23 @@ def test_resolve_provider_invalid_explicit_falls_back_to_host() -> None:
assert server._resolve_provider({"provider": 123}, "https://www.cian.ru/x") == "cian"
def test_resolve_provider_uses_source_field() -> None:
"""browser_fetcher шлёт {"url","source"} — explicit берётся и из "source"."""
assert server._resolve_provider({"source": "avito"}, "https://example.com/x") == "avito"
assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex"
def test_resolve_provider_domclick_source_to_generic() -> None:
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
'domklik' не валиден host-detect domclick.ru generic. Намеренно: в prod
нет выделенного BROWSER_PROXY_DOMCLICK.
"""
assert (
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
)
# ── _lock_for: один лок на поставщика ────────────────────────────────────────────
@ -239,7 +256,10 @@ def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch)
responses = asyncio.run(_run())
assert all(r.status == 200 for r in responses)
assert probe.global_peak == 3, "три разных поставщика должны фетчить конкурентно"
# >=2 (не ==3): на нагруженном CI планировщик может не вывести все три корутины
# в overlap одновременно; перекрытие хотя бы двух уже доказывает параллельность
# между поставщиками. Same-provider тест (peak==1) остаётся строгим.
assert probe.global_peak >= 2, "разные поставщики должны фетчить конкурентно"
# У каждого поставщика по одному запросу — внутренний peak == 1.
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}