Merge pull request 'feat(tradein-browser): per-provider page-interval override (#1812 follow-up)' (#1826) from feat/browser-per-provider-pacing into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m2s
Deploy Trade-In / deploy (push) Successful in 1m28s

Reviewed-on: #1826
This commit is contained in:
lekss361 2026-06-20 08:47:06 +00:00
commit 7d92411347
2 changed files with 160 additions and 5 deletions

View file

@ -50,6 +50,14 @@ Per-provider модель (#1793):
коннектам предыдущей страницы дренироваться, прежде чем
открыть новую меньше пиковый параллелизм на прокси.
0 = без пейсинга.
BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER} per-provider override интервала пейсинга.
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC.
Например: BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 задаёт 18с
только для cian, не затрагивая другие провайдеры. Если env
не задан или содержит невалидное значение фолбэк на
глобальный BROWSER_MIN_PAGE_INTERVAL_S. Backward-compat:
при незаданных per-provider env поведение идентично
предыдущему (чисто аддитивный override).
BROWSER_PROXY_AVITO http-прокси для avito (fallback AVITO_PROXY_URL).
BROWSER_PROXY_CIAN http-прокси для cian (fallback CIAN_PROXY_URL).
BROWSER_PROXY_YANDEX http-прокси для yandex (fallback YANDEX_PROXY_URL).
@ -71,6 +79,7 @@ import asyncio
import base64
import logging
import os
from collections.abc import Mapping
from urllib.parse import urlparse
from aiohttp import web
@ -132,6 +141,46 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types(
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
def _resolve_min_interval(
provider: str,
environ: Mapping[str, str] | None = None,
) -> float:
"""Возвращает эффективный интервал пейсинга для провайдера.
Читает per-provider override из ``BROWSER_MIN_PAGE_INTERVAL_S_{PROVIDER}``
(PROVIDER в upper-case). Невалидное или отсутствующее значение фолбэк на
глобальный ``BROWSER_MIN_PAGE_INTERVAL_S``. Не кидает исключений.
Args:
provider: имя провайдера ("avito", "cian", "yandex", "generic").
environ: env-словарь; None ``os.environ`` (тестируемость без патча модуля).
Returns:
Эффективный интервал в секундах (0).
"""
env = environ if environ is not None else os.environ
key = f"BROWSER_MIN_PAGE_INTERVAL_S_{provider.upper()}"
raw = env.get(key)
if raw is not None:
try:
return float(raw)
except ValueError:
logger.warning(
"tradein-browser: %s=%r невалидно (не float), используем глобал %.1f",
key,
raw,
BROWSER_MIN_PAGE_INTERVAL_S,
)
return BROWSER_MIN_PAGE_INTERVAL_S
# Per-provider эффективные интервалы пейсинга, вычисляются на module-load из os.environ.
# Ключи: все известные провайдеры из PROVIDERS. Логируются на старте (_on_startup).
_MIN_PAGE_INTERVAL_BY_PROVIDER: dict[str, float] = {
p: _resolve_min_interval(p) for p in ("avito", "cian", "yandex", "generic")
}
def _provider_for_url(url: str) -> str:
"""Определяет поставщика по host URL.
@ -412,6 +461,7 @@ async def _on_startup(app: web.Application) -> None:
"параллель между %s, ≤1 на поставщика; браузеры стартуют по требованию",
list(PROVIDERS),
)
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
async def _on_cleanup(app: web.Application) -> None:
@ -549,21 +599,22 @@ async def _apply_resource_block(page: object) -> None:
async def _pace_provider(provider: str) -> None:
"""Притормаживает перед page.goto, если с прошлого goto этого провайдера прошло
меньше BROWSER_MIN_PAGE_INTERVAL_S даёт под-коннектам прошлой страницы
дренироваться, снижая пиковый параллелизм на прокси.
меньше эффективного интервала даёт под-коннектам прошлой страницы дренироваться,
снижая пиковый параллелизм на прокси.
Caller держит _locks[provider] timestamp per-provider читается/пишется без гонки.
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
No-op при BROWSER_MIN_PAGE_INTERVAL_S <= 0.
No-op при эффективном интервале провайдера <= 0.
"""
if BROWSER_MIN_PAGE_INTERVAL_S <= 0:
interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
if interval <= 0:
return
now = asyncio.get_event_loop().time()
last = _last_goto_at.get(provider)
if last is not None:
elapsed = now - last
remaining = BROWSER_MIN_PAGE_INTERVAL_S - elapsed
remaining = interval - elapsed
if remaining > 0:
logger.debug(
"tradein-browser[%s]: pacing — ждём %.2fс перед goto", provider, remaining

View file

@ -0,0 +1,104 @@
"""test_server_pacing.py — юниты для per-provider pacing-интервалов (#1812 follow-up).
Проверяет:
1. per-provider env override переопределяет глобал для конкретного провайдера;
2. отсутствие per-provider env фолбэк на глобальный BROWSER_MIN_PAGE_INTERVAL_S;
3. невалидный per-provider env (не float) фолбэк на глобал, без исключения;
4. пустая строка как per-provider env фолбэк на глобал (ValueError при float("")).
camoufox/Playwright НЕ поднимается: _resolve_min_interval вызывается с фейковым environ.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_pacing.py -q
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
# ── _resolve_min_interval ────────────────────────────────────────────────────────
def test_per_provider_override_replaces_global() -> None:
"""BROWSER_MIN_PAGE_INTERVAL_S_CIAN=18 + глобал=12 → cian=18, avito=12."""
fake_env = {
"BROWSER_MIN_PAGE_INTERVAL_S": "12",
"BROWSER_MIN_PAGE_INTERVAL_S_CIAN": "18",
}
# Патчим глобал в модуле, чтобы фолбэк брался из него (не из os.environ при load).
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0
try:
assert server._resolve_min_interval("cian", fake_env) == 18.0
assert server._resolve_min_interval("avito", fake_env) == 12.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_no_per_provider_env_falls_back_to_global() -> None:
"""Нет BROWSER_MIN_PAGE_INTERVAL_S_YANDEX → фолбэк на глобал."""
fake_env: dict[str, str] = {}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 5.0
try:
result = server._resolve_min_interval("yandex", fake_env)
assert result == 5.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_invalid_per_provider_env_falls_back_to_global() -> None:
"""Невалидный env (не float) → фолбэк на глобал, без исключения."""
fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "abc"}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 7.0
try:
result = server._resolve_min_interval("avito", fake_env)
assert result == 7.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_empty_string_per_provider_env_falls_back_to_global() -> None:
"""Пустая строка env → float("") → ValueError → фолбэк на глобал."""
fake_env = {"BROWSER_MIN_PAGE_INTERVAL_S_GENERIC": ""}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 3.0
try:
result = server._resolve_min_interval("generic", fake_env)
assert result == 3.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_zero_per_provider_env_disables_pacing() -> None:
"""BROWSER_MIN_PAGE_INTERVAL_S_AVITO=0 → 0.0 (без пейсинга для avito)."""
fake_env = {
"BROWSER_MIN_PAGE_INTERVAL_S": "12",
"BROWSER_MIN_PAGE_INTERVAL_S_AVITO": "0",
}
original_global = server.BROWSER_MIN_PAGE_INTERVAL_S
server.BROWSER_MIN_PAGE_INTERVAL_S = 12.0
try:
assert server._resolve_min_interval("avito", fake_env) == 0.0
# Другие провайдеры без override получают глобал.
assert server._resolve_min_interval("cian", fake_env) == 12.0
finally:
server.BROWSER_MIN_PAGE_INTERVAL_S = original_global
def test_all_providers_covered() -> None:
"""Все 4 провайдера (avito/cian/yandex/generic) обрабатываются резолвером."""
for provider in ("avito", "cian", "yandex", "generic"):
result = server._resolve_min_interval(provider, {})
assert isinstance(result, float), f"{provider}: ожидали float, получили {type(result)}"