fix(tradein/scraper): не ходить через мёртвый запасной прокси #2634
16 changed files with 766 additions and 13 deletions
|
|
@ -219,6 +219,11 @@ class RealScraperConfig:
|
|||
def use_proxy_pool_browser(self) -> bool:
|
||||
return _settings.use_proxy_pool_browser
|
||||
|
||||
# ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ──
|
||||
@property
|
||||
def environment(self) -> str:
|
||||
return _settings.environment
|
||||
|
||||
|
||||
class RealProxyProvider:
|
||||
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
трогается (golden-parity: браузер юзает свой env-прокси BROWSER_PROXY_*).
|
||||
- use_pool=True + пул выдал lease → тело содержит "proxy"=lease.url + "proxy_kind";
|
||||
на выходе mark_health(ok) + release (в finally — lease не течёт).
|
||||
- use_pool=True + пул пуст (acquire→None) → тела без "proxy", НЕ падаем.
|
||||
- use_pool=True + acquire бросил → fallback без "proxy", НЕ падаем.
|
||||
- use_pool=True + пул пуст (acquire→None) + dev (дефолт) → тело без "proxy", НЕ падаем.
|
||||
- use_pool=True + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError,
|
||||
POST /fetch НЕ отправляется вовсе.
|
||||
- use_pool=True + acquire бросил → fallback без "proxy" (dev), НЕ падаем.
|
||||
- fetch кинул → mark_health(ok=False) + release всё равно (finally).
|
||||
|
||||
httpx полностью замокан: fetcher._client подменяется MagicMock'ом.
|
||||
|
|
@ -20,6 +22,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
import pytest
|
||||
from scraper_kit.browser_fetcher import BrowserFetcher
|
||||
from scraper_kit.contracts import ProxyLease
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
|
||||
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
||||
|
|
@ -167,6 +170,92 @@ async def test_fetch_json_pool_off_no_proxy() -> None:
|
|||
assert "proxy" not in body
|
||||
|
||||
|
||||
# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ────────────────
|
||||
|
||||
|
||||
async def test_fetch_pool_empty_dev_falls_back_no_proxy() -> None:
|
||||
"""Пул пуст + dev (дефолт environment) → прежнее поведение: тело без 'proxy'."""
|
||||
provider = _FakeProxyProvider(None)
|
||||
client = _mock_client({"html": "<ok>"})
|
||||
bf = _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
|
||||
|
||||
html = await bf.fetch("https://cian.ru/x")
|
||||
|
||||
assert html == "<ok>"
|
||||
body = client.post.call_args.kwargs["json"]
|
||||
assert "proxy" not in body
|
||||
assert provider.acquired == ["cian"]
|
||||
|
||||
|
||||
async def test_fetch_pool_empty_prod_refuses_no_http_post() -> None:
|
||||
"""Пул пуст + prod → NoProxyAvailableError, POST /fetch НЕ отправляется вовсе.
|
||||
|
||||
client.post настроен падать AssertionError на ЛЮБОМ вызове — если бы код тихо
|
||||
зафолбэчился (регрессия), тест упал бы с несовпадающим типом исключения, а не
|
||||
просто "прошёл иначе" (falsifiable).
|
||||
"""
|
||||
provider = _FakeProxyProvider(None)
|
||||
client = MagicMock()
|
||||
client.post = AsyncMock(side_effect=AssertionError("POST /fetch must NOT happen"))
|
||||
bf = _fetcher(
|
||||
client, source="avito", proxy_provider=provider, use_pool=True, environment="production"
|
||||
)
|
||||
|
||||
with pytest.raises(NoProxyAvailableError):
|
||||
await bf.fetch("https://avito.ru/x")
|
||||
|
||||
client.post.assert_not_called()
|
||||
assert provider.acquired == ["avito"]
|
||||
assert provider.released == []
|
||||
assert provider.health == []
|
||||
|
||||
|
||||
async def test_fetch_json_pool_empty_prod_refuses_no_http_post() -> None:
|
||||
"""Та же гарантия для fetch_json: prod + пул пуст → отказ, без POST."""
|
||||
provider = _FakeProxyProvider(None)
|
||||
client = MagicMock()
|
||||
client.post = AsyncMock(side_effect=AssertionError("POST /fetch-json must NOT happen"))
|
||||
bf = _fetcher(
|
||||
client, source="cian", proxy_provider=provider, use_pool=True, environment="production"
|
||||
)
|
||||
|
||||
with pytest.raises(NoProxyAvailableError):
|
||||
await bf.fetch_json("https://cian.ru/api")
|
||||
|
||||
client.post.assert_not_called()
|
||||
|
||||
|
||||
async def test_fetch_pool_lease_prod_unaffected() -> None:
|
||||
"""Пул выдал lease в prod — поведение БЕЗ ИЗМЕНЕНИЙ (это не случай отказа)."""
|
||||
lease = ProxyLease(id=11, url="http://u:p@pool:8080", kind="http")
|
||||
provider = _FakeProxyProvider(lease)
|
||||
client = _mock_client({"html": "<ok>"})
|
||||
bf = _fetcher(
|
||||
client, source="avito", proxy_provider=provider, use_pool=True, environment="production"
|
||||
)
|
||||
|
||||
html = await bf.fetch("https://avito.ru/x")
|
||||
|
||||
assert html == "<ok>"
|
||||
body = client.post.call_args.kwargs["json"]
|
||||
assert body["proxy"] == lease.url
|
||||
assert provider.health == [(11, True)]
|
||||
assert provider.released == [11]
|
||||
|
||||
|
||||
def test_no_proxy_error_distinguishable_from_site_block() -> None:
|
||||
"""NoProxyAvailableError — отдельный тип с provider-атрибутом, текст без
|
||||
"blocked"/"captcha"/"banned" — programmatically отличим от бана площадкой.
|
||||
"""
|
||||
err = NoProxyAvailableError("avito")
|
||||
assert err.provider == "avito"
|
||||
lowered = str(err).lower()
|
||||
assert "blocked" not in lowered
|
||||
assert "captcha" not in lowered
|
||||
assert "banned" not in lowered
|
||||
assert "no proxy available" in lowered
|
||||
|
||||
|
||||
# ── fetch() origin passthrough (DomClick SERP-anchor, зеркалит fetch_json) ───────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
endpoint: str,
|
||||
proxy_provider: object | None = None,
|
||||
use_pool: bool = False,
|
||||
environment: str = "dev",
|
||||
) -> None:
|
||||
calls.append(
|
||||
{
|
||||
|
|
@ -71,6 +72,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
|
|||
"endpoint": endpoint,
|
||||
"proxy_provider": proxy_provider,
|
||||
"use_pool": use_pool,
|
||||
"environment": environment,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
"""#2616 шаг 1: каждый прямой BrowserFetcher(...) в pipeline.py обязан прокидывать
|
||||
environment=.
|
||||
|
||||
Ревью PR #2634 нашло: build_browser_fetcher() (providers/_base.py) — единственное
|
||||
место, где environment прокидывался, а avito-пути pipeline.py конструируют
|
||||
BrowserFetcher напрямую → guard NoProxyAvailableError был мёртв ровно для
|
||||
источника, ради которого писался (#2613). Юнит-тесты этого не ловили, потому что
|
||||
конструировали фетчер сами, а не путём pipeline.
|
||||
|
||||
Source-level гард дешевле интеграционного прогона pipeline и падает на первом же
|
||||
новом construction-site без environment=.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_PIPELINE = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "packages"
|
||||
/ "scraper-kit"
|
||||
/ "src"
|
||||
/ "scraper_kit"
|
||||
/ "orchestration"
|
||||
/ "pipeline.py"
|
||||
)
|
||||
|
||||
|
||||
def test_every_direct_browserfetcher_construction_threads_environment() -> None:
|
||||
src = _PIPELINE.read_text(encoding="utf-8")
|
||||
# Все вызовы конструктора BrowserFetcher( ... ) — балансировку скобок не пишем,
|
||||
# берём консервативное окно в 15 строк после открытия вызова.
|
||||
sites = [m.start() for m in re.finditer(r"BrowserFetcher\(\n", src)]
|
||||
assert sites, "pipeline.py больше не конструирует BrowserFetcher напрямую? Обнови/удали тест."
|
||||
missing: list[str] = []
|
||||
for pos in sites:
|
||||
window = src[pos : pos + 700]
|
||||
if "environment=" not in window:
|
||||
line_no = src[:pos].count("\n") + 1
|
||||
missing.append(f"pipeline.py:{line_no}")
|
||||
assert not missing, (
|
||||
f"Прямые BrowserFetcher(...) без environment= ({missing}): guard "
|
||||
"NoProxyAvailableError (#2616) для них мёртв — добавь "
|
||||
'environment=getattr(config, "environment", "dev").'
|
||||
)
|
||||
|
|
@ -3,10 +3,13 @@
|
|||
Покрывает инвариант ship-dark + fallback на уровне helper'а `curl_proxy_url` и
|
||||
class-based провайдера (YandexValuationScraper):
|
||||
- флаг off / proxy_provider=None → env-прокси, пул не трогается (golden-parity);
|
||||
- флаг on + пул пуст (acquire→None) → fallback env, не падаем;
|
||||
- флаг on + lease → fetch через lease.url, mark_health вызван, release в finally;
|
||||
- флаг on + пул пуст (acquire→None) + dev → fallback env, не падаем;
|
||||
- флаг on + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError,
|
||||
HTTP-запрос НЕ выполняется, на env НЕ идём;
|
||||
- флаг on + lease → fetch через lease.url, mark_health вызван, release в finally
|
||||
(в prod и dev одинаково — пул выдал лизу, отказа быть не должно);
|
||||
- исключение внутри блока → mark_health(ok=False) + release всё равно (lease не течёт);
|
||||
- acquire кинул → fallback env (сбор не ломаем).
|
||||
- acquire кинул → fallback env (dev) / NoProxyAvailableError (prod).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,6 +25,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
|
|||
|
||||
from scraper_kit.contracts import ProxyLease
|
||||
from scraper_kit.providers._proxy import curl_proxy_url
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
# ── Фейки ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -30,6 +34,9 @@ from scraper_kit.providers._proxy import curl_proxy_url
|
|||
class _FakeConfig:
|
||||
use_proxy_pool_curl: bool = False
|
||||
scraper_proxy_url: str | None = None
|
||||
# #2616 шаг 1: дефолт "dev" — существующие тесты (не задающие поле явно) не
|
||||
# затрагиваются новым prod-отказом, ведут себя ровно как до этого изменения.
|
||||
environment: str = "dev"
|
||||
|
||||
|
||||
class _SpyProvider:
|
||||
|
|
@ -121,6 +128,86 @@ def test_acquire_raises_falls_back_to_env() -> None:
|
|||
assert spy.release_calls == []
|
||||
|
||||
|
||||
# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ────────────────
|
||||
|
||||
|
||||
def test_flag_on_empty_pool_dev_falls_back_to_env() -> None:
|
||||
"""Пул пуст + явный dev-признак → прежнее поведение (env-фолбэк, не падаем)."""
|
||||
cfg = _FakeConfig(use_proxy_pool_curl=True, environment="dev")
|
||||
spy = _SpyProvider(None)
|
||||
with curl_proxy_url(cfg, spy, "avito", env_fallback_url="http://env:3128") as url:
|
||||
assert url == "http://env:3128"
|
||||
assert spy.acquire_calls == ["avito"]
|
||||
assert spy.mark_health_calls == []
|
||||
assert spy.release_calls == []
|
||||
|
||||
|
||||
def test_flag_on_empty_pool_prod_refuses_no_http_request() -> None:
|
||||
"""Пул пуст + прод-признак → NoProxyAvailableError, HTTP-запрос НЕ выполняется.
|
||||
|
||||
Заглушка `_boom` падает на ЛЮБОМ вызове внутри `with`-блока (там, где в
|
||||
реальном коде было бы `session.get(...)`). Если бы curl_proxy_url тихо
|
||||
fallback'нулся на env (регрессия), `_boom()` выполнился бы и поднял
|
||||
AssertionError, который `pytest.raises(NoProxyAvailableError)` НЕ поймает —
|
||||
тест упал бы с несовпадающим типом исключения (falsifiable).
|
||||
"""
|
||||
cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production")
|
||||
spy = _SpyProvider(None) # acquire → None (пул пуст)
|
||||
|
||||
def _boom() -> None:
|
||||
raise AssertionError("HTTP request must NOT happen — proxy pool empty in prod")
|
||||
|
||||
with pytest.raises(NoProxyAvailableError):
|
||||
with curl_proxy_url(cfg, spy, "avito", env_fallback_url="http://env:3128"):
|
||||
_boom() # НЕ должно достигаться — raise происходит ДО yield
|
||||
|
||||
assert spy.acquire_calls == ["avito"]
|
||||
# lease не выдан → mark_health/release не зовём (нечего освобождать)
|
||||
assert spy.mark_health_calls == []
|
||||
assert spy.release_calls == []
|
||||
|
||||
|
||||
def test_acquire_raises_prod_refuses_no_env_fallback() -> None:
|
||||
"""acquire() упал + прод-признак → NoProxyAvailableError, не мёртвый env."""
|
||||
cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production")
|
||||
spy = _SpyProvider(_LEASE, acquire_raises=True)
|
||||
with pytest.raises(NoProxyAvailableError):
|
||||
with curl_proxy_url(cfg, spy, "cian", env_fallback_url="http://env:3128"):
|
||||
pytest.fail("must not enter with-block body")
|
||||
assert spy.release_calls == []
|
||||
|
||||
|
||||
def test_flag_on_lease_prod_unaffected() -> None:
|
||||
"""Пул выдал прокси в прод — поведение БЕЗ ИЗМЕНЕНИЙ (не наш случай отказа)."""
|
||||
cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production")
|
||||
spy = _SpyProvider(_LEASE)
|
||||
with curl_proxy_url(cfg, spy, "yandex", env_fallback_url="http://env:3128") as url:
|
||||
assert url == _LEASE.url
|
||||
assert spy.acquire_calls == ["yandex"]
|
||||
assert spy.mark_health_calls == [(7, True)]
|
||||
assert spy.release_calls == [7]
|
||||
|
||||
|
||||
def test_no_proxy_error_distinguishable_from_site_block() -> None:
|
||||
"""Причина отказа ("нет прокси", наша инфраструктура) programmatically отличима
|
||||
от блокировки площадкой: отдельный exception-тип (не AvitoBlockedError/generic),
|
||||
структурный provider-атрибут, текст без "blocked"/"captcha"/"banned".
|
||||
"""
|
||||
cfg = _FakeConfig(use_proxy_pool_curl=True, environment="production")
|
||||
spy = _SpyProvider(None)
|
||||
with pytest.raises(NoProxyAvailableError) as exc_info:
|
||||
with curl_proxy_url(cfg, spy, "cian", env_fallback_url="http://env:3128"):
|
||||
pass
|
||||
err = exc_info.value
|
||||
assert err.provider == "cian"
|
||||
assert not isinstance(err, LookupError) # не путается с "не найдено"-семантикой
|
||||
lowered = str(err).lower()
|
||||
assert "blocked" not in lowered
|
||||
assert "captcha" not in lowered
|
||||
assert "banned" not in lowered
|
||||
assert "no proxy available" in lowered
|
||||
|
||||
|
||||
# ── YandexValuationScraper: lease держится на всё время сессии ─────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ def test_scraper_config_satisfies_protocol() -> None:
|
|||
assert isinstance(config.use_proxy_pool_curl, bool)
|
||||
# #2164 P4: флаг browser-пула присутствует и bool.
|
||||
assert isinstance(config.use_proxy_pool_browser, bool)
|
||||
# #2616 шаг 1: признак окружения присутствует и строка (проксирует settings.environment).
|
||||
assert isinstance(config.environment, str)
|
||||
|
||||
|
||||
def test_proxy_provider_satisfies_protocol() -> None:
|
||||
|
|
|
|||
|
|
@ -166,3 +166,27 @@ def test_build_browser_fetcher_explicit_timeout_override() -> None:
|
|||
fetcher = build_browser_fetcher(config, "yandex", fetch_timeout_s=30.0) # type: ignore[arg-type]
|
||||
|
||||
assert fetcher._fetch_timeout_s == 30.0
|
||||
|
||||
|
||||
# ── environment threading (#2616 шаг 1) ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_browser_fetcher_defaults_environment_dev_when_config_lacks_field() -> None:
|
||||
"""_FakeConfig (минимальная заглушка) не имеет поля `environment` — getattr-дефолт
|
||||
"dev" защищает от AttributeError и не меняет поведение старых/минимальных фейков.
|
||||
"""
|
||||
config = _FakeConfig(endpoint="http://browser:3000")
|
||||
|
||||
fetcher = build_browser_fetcher(config, "cian") # type: ignore[arg-type]
|
||||
|
||||
assert fetcher._environment == "dev"
|
||||
|
||||
|
||||
def test_build_browser_fetcher_threads_environment_from_config() -> None:
|
||||
"""config.environment (когда есть) прокидывается в BrowserFetcher как есть."""
|
||||
config = _FakeConfig(endpoint="http://browser:3000", use_pool=True)
|
||||
config.environment = "production" # type: ignore[attr-defined]
|
||||
|
||||
fetcher = build_browser_fetcher(config, "avito") # type: ignore[arg-type]
|
||||
|
||||
assert fetcher._environment == "production"
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@ Per-provider модель (#1793):
|
|||
CIAN_PROXY_URL/YANDEX_PROXY_URL могут быть socks5 с авторизацией — playwright
|
||||
их не умеет, поэтому они лишь fallback (если кто-то положил http туда же).
|
||||
|
||||
ENVIRONMENT — "production" в прод-контейнерах, иначе "dev" (дефолт).
|
||||
#2616 шаг 1: прод + нет НИ override в теле, НИ ЛЮБОГО
|
||||
env-прокси для provider'а → отказ (503, явная причина),
|
||||
а НЕ launch camoufox без proxy (= прямое подключение с IP
|
||||
сервера). В dev отсутствие прокси легитимно (см.
|
||||
_no_live_proxy). Env-переменная, которая ЗАДАНА, но мертва
|
||||
(407/connection refused — #2613) — этот guard НЕ триггерит,
|
||||
удаление мёртвых переменных отдельным шагом ПОСЛЕ #2616.
|
||||
|
||||
Контракт /login (провалидировано вживую 2026-05-31, Cian email+пароль без SMS):
|
||||
pre_click_selectors — список селекторов для последовательного клика до формы;
|
||||
каждый клик non-fatal (пропускается при отсутствии элемента). Двухшаговый
|
||||
|
|
@ -90,6 +99,12 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# ── конфигурация из env ────────────────────────────────────────────────────────
|
||||
|
||||
# #2616 шаг 1: признак окружения. "production" в прод-контейнерах (ENV: ENVIRONMENT,
|
||||
# см. app.core.config.Settings.environment в основном backend). Дефолт "dev" — как и
|
||||
# везде в проекте (Settings.environment: str = "dev").
|
||||
ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
|
||||
IS_PROD: bool = ENVIRONMENT == "production"
|
||||
|
||||
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
|
||||
BROWSER_RECYCLE_PAGES: int = int(os.environ.get("BROWSER_RECYCLE_PAGES", "15"))
|
||||
BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "60000"))
|
||||
|
|
@ -227,6 +242,20 @@ def _provider_proxy(provider: str) -> str | None:
|
|||
return os.environ.get(primary) or os.environ.get(fallback)
|
||||
|
||||
|
||||
def _no_live_proxy(provider: str, proxy_override: str | None) -> bool:
|
||||
"""True если нет НИ explicit proxy в теле запроса, НИ env-прокси для поставщика.
|
||||
|
||||
#2616 шаг 1: используется ТОЛЬКО в prod (IS_PROD) — в dev отсутствие прокси
|
||||
легитимно (прямое подключение для локальной разработки/тестов, см. #2616 issue
|
||||
пункт 2). Отдельная функция от `_provider_proxy`, потому что "нет живого прокси"
|
||||
здесь означает буквально "нет НИКАКОГО прокси" (ни override, ни env) — единственный
|
||||
случай, который код МОЖЕТ проверить без сетевой пробы. Env-переменная, что задана
|
||||
но мертва (407/connection refused — проверено вживую #2613), эту функцию не
|
||||
триггерит: удаление мёртвых переменных — отдельный шаг #2616 ПОСЛЕ этой правки.
|
||||
"""
|
||||
return proxy_override is None and _provider_proxy(provider) is None
|
||||
|
||||
|
||||
def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
|
||||
"""Парсит proxy URL → camoufox proxy dict.
|
||||
|
||||
|
|
@ -627,6 +656,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
|||
provider = _resolve_provider(body, url)
|
||||
proxy_override = _resolve_proxy_override(body, provider)
|
||||
|
||||
# #2616 шаг 1: прод + нет НИ override, НИ env-прокси → явный отказ ДО лока/launch'а,
|
||||
# а не тихий заход camoufox напрямую с IP сервера (dev это легитимно, см. _no_live_proxy).
|
||||
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: /fetch отказ — нет прокси в теле и нет env-прокси "
|
||||
"(prod) — не подключаемся напрямую с IP сервера (#2616)",
|
||||
provider,
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "no proxy configured — refusing direct connection (prod)"}, status=503
|
||||
)
|
||||
|
||||
lock = await _lock_for(provider)
|
||||
async with lock:
|
||||
# Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка —
|
||||
|
|
@ -693,6 +734,17 @@ async def fetch_json_handler(request: web.Request) -> web.Response:
|
|||
provider = _resolve_provider(body, url)
|
||||
proxy_override = _resolve_proxy_override(body, provider)
|
||||
|
||||
# #2616 шаг 1: см. fetch_handler — прод + нет прокси вообще → отказ, не direct-IP.
|
||||
if IS_PROD and _no_live_proxy(provider, proxy_override):
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: /fetch-json отказ — нет прокси в теле и нет env-прокси "
|
||||
"(prod) — не подключаемся напрямую с IP сервера (#2616)",
|
||||
provider,
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "no proxy configured — refusing direct connection (prod)"}, status=503
|
||||
)
|
||||
|
||||
lock = await _lock_for(provider)
|
||||
async with lock:
|
||||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||
|
|
@ -1116,6 +1168,18 @@ async def login_handler(request: web.Request) -> web.Response:
|
|||
|
||||
provider = _resolve_provider(body, body["url"])
|
||||
|
||||
# #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) →
|
||||
# отказ, не direct-IP.
|
||||
if IS_PROD and _no_live_proxy(provider, None):
|
||||
logger.warning(
|
||||
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — "
|
||||
"не подключаемся напрямую с IP сервера (#2616)",
|
||||
provider,
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "no proxy configured — refusing direct connection (prod)"}, status=503
|
||||
)
|
||||
|
||||
lock = await _lock_for(provider)
|
||||
async with lock:
|
||||
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
|
||||
|
|
|
|||
310
tradein-mvp/browser/test_server_no_proxy_refusal.py
Normal file
310
tradein-mvp/browser/test_server_no_proxy_refusal.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""test_server_no_proxy_refusal.py — #2616 шаг 1: не идти напрямую с IP сервера.
|
||||
|
||||
tradein-browser (per-provider camoufox) раньше в прод-контейнере, если ни тело
|
||||
запроса, ни ЛЮБАЯ env-переменная не несли proxy, лениво запускал camoufox БЕЗ
|
||||
proxy — прямое подключение с IP сервера. Мертвые env-переменные (407/connection
|
||||
refused, #2613) сегодня этого не триггерят (запуск падает раньше, на invalid-proxy
|
||||
проверке camoufox) — но как только шаг 2 issue #2616 их удалит, сценарий станет
|
||||
реальным. Этот файл фиксирует контракт ЗАРАНЕЕ (`_no_live_proxy` + `IS_PROD` guard
|
||||
в fetch_handler/fetch_json_handler/login_handler), до того как #2616 шаг 2 сделает
|
||||
его наблюдаемым в проде.
|
||||
|
||||
Проверяет:
|
||||
1. `_no_live_proxy` — pure-function поведение (override / env приоритеты).
|
||||
2. prod + нет override + нет env → 503 с распознаваемой причиной, `_ensure_browser`
|
||||
(и, соответственно, `_launch_browser`) НЕ вызывается вовсе — camoufox не
|
||||
стартует без прокси.
|
||||
3. dev (или явный IS_PROD=False) + то же самое → прежнее поведение (lazy launch
|
||||
без прокси легитимен для локальной разработки).
|
||||
4. prod + proxy присутствует (override ИЛИ env) → guard не триггерит, поведение
|
||||
без изменений (это НЕ случай отказа).
|
||||
5. Текст отказа отличим от "browser unavailable (proxy may be down)" (launch
|
||||
failed на мёртвом-но-заданном прокси) — разные причины, разный текст.
|
||||
|
||||
camoufox НЕ запускается: `_ensure_browser` мокается (см. test_server_proxy_override.py).
|
||||
Хендлер вызывается напрямую (make_mocked_request не поднимает сокет).
|
||||
|
||||
Запуск (из tradein-mvp/browser/)::
|
||||
|
||||
python -m pytest test_server_no_proxy_refusal.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
_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)
|
||||
|
||||
|
||||
def _json_body(response: Any) -> dict[str, Any]:
|
||||
return json.loads(response.body.decode())
|
||||
|
||||
|
||||
async def _coro(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _make_request(path: str, body: dict[str, Any]) -> Any:
|
||||
request = make_mocked_request("POST", path)
|
||||
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Чистое per-provider состояние + НЕТ live env-прокси ни для одного провайдера.
|
||||
|
||||
Явно чистим все env-переменные, которые `_provider_proxy` умеет читать — тест
|
||||
не должен зависеть от того, что реально задано в shell/CI-раннере.
|
||||
"""
|
||||
monkeypatch.setattr(server, "_browsers", {})
|
||||
monkeypatch.setattr(server, "_browser_cms", {})
|
||||
monkeypatch.setattr(server, "_page_counters", {})
|
||||
monkeypatch.setattr(server, "_locks", {})
|
||||
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||
monkeypatch.setattr(server, "_last_goto_at", {})
|
||||
monkeypatch.setattr(server, "_launched_proxy", {})
|
||||
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||
for var in (
|
||||
"BROWSER_PROXY_AVITO",
|
||||
"AVITO_PROXY_URL",
|
||||
"BROWSER_PROXY_CIAN",
|
||||
"CIAN_PROXY_URL",
|
||||
"BROWSER_PROXY_YANDEX",
|
||||
"YANDEX_PROXY_URL",
|
||||
"SCRAPER_PROXY_URL",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def _forbid_ensure_browser(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
"""Мок `_ensure_browser`, падающий AssertionError на любом вызове — falsification:
|
||||
если guard регрессирует (перестаёт отказывать ДО lock/launch), тест ловит это
|
||||
несовпадением типа исключения, а не просто другим статус-кодом.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
|
||||
async def _boom(provider: str, proxy_override: str | None = None) -> bool:
|
||||
calls.append(provider)
|
||||
raise AssertionError("_ensure_browser must NOT be called — no proxy configured, prod")
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _boom)
|
||||
return calls
|
||||
|
||||
|
||||
# ── _no_live_proxy: pure-function поведение ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_live_proxy_true_without_override_and_env() -> None:
|
||||
assert server._no_live_proxy("avito", None) is True
|
||||
|
||||
|
||||
def test_no_live_proxy_false_with_override() -> None:
|
||||
assert server._no_live_proxy("avito", "http://pool:8080") is False
|
||||
|
||||
|
||||
def test_no_live_proxy_false_with_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080")
|
||||
assert server._no_live_proxy("avito", None) is False
|
||||
|
||||
|
||||
# ── fetch_handler: prod-отказ, никакого launch'а ────────────────────────────────
|
||||
|
||||
|
||||
def test_fetch_handler_prod_refuses_without_launch_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
calls = _forbid_ensure_browser(monkeypatch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||||
)
|
||||
|
||||
assert response.status == 503
|
||||
assert calls == [] # _ensure_browser НЕ вызывался вовсе
|
||||
body = _json_body(response)
|
||||
assert "no proxy configured" in body["error"]
|
||||
|
||||
|
||||
def test_fetch_json_handler_prod_refuses_without_launch_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
calls = _forbid_ensure_browser(monkeypatch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_json_handler(
|
||||
_make_request("/fetch-json", {"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 503
|
||||
assert calls == []
|
||||
body = _json_body(response)
|
||||
assert "no proxy configured" in body["error"]
|
||||
|
||||
|
||||
def test_login_handler_prod_refuses_without_launch_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
calls = _forbid_ensure_browser(monkeypatch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.login_handler(
|
||||
_make_request(
|
||||
"/login",
|
||||
{
|
||||
"url": "https://www.cian.ru/login",
|
||||
"email": "a@b.ru",
|
||||
"password": "x",
|
||||
"email_selector": "#e",
|
||||
"password_selector": "#p",
|
||||
"submit_selector": "#s",
|
||||
"success_cookie": "sid",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 503
|
||||
assert calls == []
|
||||
body = _json_body(response)
|
||||
assert "no proxy configured" in body["error"]
|
||||
|
||||
|
||||
# ── dev: прежнее поведение (lazy launch без прокси легитимен) ──────────────────
|
||||
|
||||
|
||||
def test_fetch_handler_dev_still_attempts_launch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""IS_PROD=False (dev) + нет прокси вообще → guard НЕ триггерит, _ensure_browser
|
||||
вызывается как раньше (dev/no-op direct connection — легитимно для локалки).
|
||||
"""
|
||||
monkeypatch.setattr(server, "IS_PROD", False)
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
calls.append((provider, proxy_override))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
server._browsers["avito"] = object() # чтобы дальше не падать на _do_fetch
|
||||
|
||||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||||
return "<ok>"
|
||||
|
||||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert calls == [("avito", None)] # launch БЫЛ атакован — dev-путь не блокирует
|
||||
|
||||
|
||||
# ── prod + proxy присутствует (override ИЛИ env) → guard не триггерит ──────────
|
||||
|
||||
|
||||
def test_fetch_handler_prod_with_env_proxy_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://env:8080") # "живой" с т.з. guard'а
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
calls.append((provider, proxy_override))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
server._browsers["avito"] = object()
|
||||
|
||||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||||
return "<ok>"
|
||||
|
||||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert calls == [("avito", None)] # guard не заблокировал — env-прокси "есть"
|
||||
|
||||
|
||||
def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||||
calls.append((provider, proxy_override))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||||
server._browsers["avito"] = object()
|
||||
|
||||
async def _fake_do_fetch(*_a: Any, **_kw: Any) -> str:
|
||||
return "<ok>"
|
||||
|
||||
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
|
||||
|
||||
response = asyncio.run(
|
||||
server.fetch_handler(
|
||||
_make_request(
|
||||
"/fetch",
|
||||
{
|
||||
"url": "https://www.avito.ru/x",
|
||||
"proxy": "http://pool:8080",
|
||||
"proxy_kind": "http",
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert calls == [("avito", "http://pool:8080")]
|
||||
|
||||
|
||||
# ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────
|
||||
|
||||
|
||||
def test_refusal_message_distinguishable_from_launch_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
""""нет прокси вообще" (наша инфраструктура) — другой текст, чем "browser
|
||||
unavailable (proxy may be down)" (launch упал на мёртвом-но-заданном прокси).
|
||||
"""
|
||||
monkeypatch.setattr(server, "IS_PROD", True)
|
||||
_forbid_ensure_browser(monkeypatch)
|
||||
|
||||
no_proxy_resp = asyncio.run(
|
||||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||||
)
|
||||
no_proxy_text = _json_body(no_proxy_resp)["error"]
|
||||
|
||||
# launch failure (дохлый-но-заданный env) — существующий, другой путь.
|
||||
monkeypatch.setenv("BROWSER_PROXY_AVITO", "http://dead:8080")
|
||||
|
||||
async def _fails_launch(provider: str, proxy_override: str | None = None) -> bool:
|
||||
return False # camoufox launch упал (InvalidProxy) — существующая семантика
|
||||
|
||||
monkeypatch.setattr(server, "_ensure_browser", _fails_launch)
|
||||
launch_failed_resp = asyncio.run(
|
||||
server.fetch_handler(_make_request("/fetch", {"url": "https://www.avito.ru/x"}))
|
||||
)
|
||||
launch_failed_text = _json_body(launch_failed_resp)["error"]
|
||||
|
||||
assert no_proxy_text != launch_failed_text
|
||||
assert "no proxy configured" in no_proxy_text
|
||||
assert "browser unavailable" in launch_failed_text
|
||||
|
|
@ -67,6 +67,10 @@ services:
|
|||
# Сколько /fetch параллельно. Дефолт 4; ops поднимет до 8 в .env.runtime после смоука.
|
||||
BROWSER_CONCURRENCY: ${BROWSER_CONCURRENCY:-4}
|
||||
# SCRAPER_PROXY_URL читается из .env.runtime (см. env_file выше)
|
||||
# #2616 шаг 1: server.py IS_PROD-guard (отказ 503 вместо direct-IP camoufox при
|
||||
# отсутствии прокси) активен только при ENVIRONMENT=production — без этой
|
||||
# строки guard молча спит (дефолт "dev").
|
||||
ENVIRONMENT: production
|
||||
expose:
|
||||
- "3000"
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ from typing import TYPE_CHECKING
|
|||
|
||||
import httpx
|
||||
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scraper_kit.contracts import ProxyProvider
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ class BrowserFetcher:
|
|||
endpoint: str,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
use_pool: bool = False,
|
||||
environment: str = "dev",
|
||||
) -> None:
|
||||
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
|
||||
# роутит /fetch по нему на отдельный браузер+прокси, когда включён
|
||||
|
|
@ -72,12 +75,19 @@ class BrowserFetcher:
|
|||
# После fetch — mark_health + release (finally, lease не течёт). use_pool=False
|
||||
# (дефолт) ИЛИ пустой пул → proxy в теле не шлём, браузер юзает свой env-прокси
|
||||
# (BROWSER_PROXY_*), поведение не меняется.
|
||||
#
|
||||
# environment (#2616 шаг 1): "production" в прод-контейнерах (ScraperConfig.
|
||||
# environment, ENV ENVIRONMENT). use_pool=True + acquire() вернул None/упал +
|
||||
# environment=="production" → НЕ падаем на env-прокси (все мертвы, #2613) —
|
||||
# NoProxyAvailableError вместо тела без "proxy" (см. _pool_proxy). Дефолт "dev" —
|
||||
# легитимный fallback на env для dev/test, поведение не меняется.
|
||||
self._source = source
|
||||
self._fetch_timeout_s = fetch_timeout_s
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._endpoint: str | None = endpoint
|
||||
self._proxy_provider = proxy_provider
|
||||
self._use_pool = use_pool
|
||||
self._environment = environment
|
||||
|
||||
# ── lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -224,10 +234,17 @@ class BrowserFetcher:
|
|||
|
||||
use_pool=False (дефолт) ИЛИ proxy_provider=None → yield (None, None): proxy в тело
|
||||
/fetch не кладётся, tradein-browser юзает свой env-прокси (BROWSER_PROXY_*),
|
||||
поведение не меняется. Флаг on + пул выдал lease → yield (lease.url, lease.kind);
|
||||
на выходе mark_health(ok) + release (в finally — lease не течёт). Пул пуст/ошибка
|
||||
acquire → fallback (None, None), НЕ падаем (сбор цел). ok=False если внутри блока
|
||||
поднялось исключение (бан/сетевая ошибка) → mark_health(False).
|
||||
поведение не меняется (легитимный dev/no-op путь). Флаг on + пул выдал lease →
|
||||
yield (lease.url, lease.kind); на выходе mark_health(ok) + release (в finally —
|
||||
lease не течёт). Пул пуст/ошибка acquire + environment != "production" → fallback
|
||||
(None, None), НЕ падаем (легитимно для dev/test). Пул пуст/ошибка acquire +
|
||||
environment == "production" (#2616 шаг 1) → env-прокси мертвы (#2613) — поднимаем
|
||||
`NoProxyAvailableError` ДО HTTP POST /fetch, а не заходим через мёртвый узел.
|
||||
ok=False если внутри блока поднялось исключение (бан/сетевая ошибка) →
|
||||
mark_health(False).
|
||||
|
||||
Raises:
|
||||
NoProxyAvailableError: см. выше — прод + пул реально задействован + пуст/упал.
|
||||
"""
|
||||
use_pool = self._use_pool and self._proxy_provider is not None
|
||||
lease = None
|
||||
|
|
@ -244,7 +261,16 @@ class BrowserFetcher:
|
|||
lease = None
|
||||
|
||||
if lease is None:
|
||||
# off / пул пуст / ошибка acquire → без proxy в теле (browser юзает env).
|
||||
if use_pool and self._environment == "production":
|
||||
# Пул реально задействован (прод) и пуст/сломан — env-прокси мертвы,
|
||||
# НЕ идём на них молча. Явный отказ ДО POST /fetch (#2616 шаг 1).
|
||||
logger.warning(
|
||||
"BrowserFetcher: proxy_pool acquire(%s) empty in production — refusing "
|
||||
"(no HTTP request), NOT falling back to dead env proxy (#2616)",
|
||||
self._source,
|
||||
)
|
||||
raise NoProxyAvailableError(self._source)
|
||||
# off / dev-test / пул пуст в dev → без proxy в теле (browser юзает env).
|
||||
yield None, None
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,12 @@ class ScraperConfig(Protocol):
|
|||
# реальной смене). Ship-dark: дефолт False → браузер берёт прокси из env
|
||||
# (BROWSER_PROXY_*), прод не меняется. True + пустой пул → fallback на env.
|
||||
use_proxy_pool_browser: bool
|
||||
# ── #2616 шаг 1: признак окружения для отказа вместо мёртвого env-fallback ──────
|
||||
# "production" в прод-контейнерах (ENV: ENVIRONMENT), иначе dev/test/local. Читают
|
||||
# curl_proxy_url (_proxy.py) и BrowserFetcher._pool_proxy — пул пуст/acquire упал +
|
||||
# окружение НЕ "production" → легитимный dev/no-op fallback (без изменений).
|
||||
# environment == "production" → NoProxyAvailableError вместо мёртвого env-прокси.
|
||||
environment: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
|
|
|||
|
|
@ -561,6 +561,9 @@ async def run_avito_pipeline(
|
|||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
# #2616 шаг 1: прямое конструирование обходит build_browser_fetcher() —
|
||||
# без environment guard NoProxyAvailableError мёртв для avito.
|
||||
environment=getattr(config, "environment", "dev"),
|
||||
)
|
||||
await browser_fetcher.__aenter__()
|
||||
own_browser = True
|
||||
|
|
@ -1039,6 +1042,9 @@ async def run_avito_city_sweep(
|
|||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
# #2616 шаг 1: см. run_avito_pipeline — environment обязателен
|
||||
# при прямом конструировании (guard иначе мёртв).
|
||||
environment=getattr(config, "environment", "dev"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
@ -1727,6 +1733,9 @@ async def run_avito_newbuilding_sweep(
|
|||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
# #2616 шаг 1: см. run_avito_pipeline — environment обязателен
|
||||
# при прямом конструировании (guard иначе мёртв).
|
||||
environment=getattr(config, "environment", "dev"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -180,13 +180,20 @@ def build_browser_fetcher(
|
|||
(120s). Yandex-провайдер передаёt здесь 30s явно (см. вызовы в
|
||||
`yandex/serp.py`/`yandex/newbuilding.py`) — единственный provider с
|
||||
отличным от дефолта таймаутом.
|
||||
|
||||
`environment=getattr(config, "environment", "dev")` (#2616 шаг 1) — прокидывается в
|
||||
`BrowserFetcher._pool_proxy`: пул пуст/сломан + прод → отказ вместо мёртвого
|
||||
env-прокси. `getattr` с дефолтом "dev" — минимальные ScraperConfig-заглушки без поля
|
||||
`environment` (старые тесты) трактуются как dev, поведение для них не меняется.
|
||||
"""
|
||||
environment = getattr(config, "environment", "dev")
|
||||
if fetch_timeout_s is None:
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
)
|
||||
return BrowserFetcher(
|
||||
source=source,
|
||||
|
|
@ -194,6 +201,7 @@ def build_browser_fetcher(
|
|||
endpoint=config.browser_http_endpoint,
|
||||
proxy_provider=proxy_provider,
|
||||
use_pool=config.use_proxy_pool_browser,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
|
||||
Инвариант ship-dark + fallback:
|
||||
- config.use_proxy_pool_curl=False (дефолт) ИЛИ proxy_provider=None → yield env-прокси
|
||||
(env_fallback_url) — curl-пути ходят ровно как сейчас, прод не меняется.
|
||||
(env_fallback_url) — curl-пути ходят ровно как сейчас, прод не меняется (легитимный
|
||||
dev/no-op путь — см. providers/cian/detail.py:118).
|
||||
- Флаг on + пул выдал lease → yield lease.url; на выходе mark_health(ok) + release(lease).
|
||||
ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка).
|
||||
- Флаг on + пул пуст/ошибка acquire → fallback на env_fallback_url, НЕ падаем (сбор цел).
|
||||
- Флаг on + пул пуст/ошибка acquire + окружение НЕ "production" (dev/test) → fallback на
|
||||
env_fallback_url, НЕ падаем (легитимно для локальной разработки/тестов).
|
||||
- Флаг on + пул пуст/ошибка acquire + окружение "production" (#2616 шаг 1) → НЕ идём на
|
||||
env-прокси (все env-прокси мертвы, проверено вживую #2613) — поднимаем
|
||||
`NoProxyAvailableError` ДО HTTP-запроса. Явный отказ вместо похода через мёртвый узел.
|
||||
|
||||
release ВСЕГДА в finally — lease не должен течь, даже если fetch кинул. mark_health/release
|
||||
обёрнуты в best-effort try (проблема пула не должна ронять сбор).
|
||||
|
|
@ -18,12 +23,24 @@ from collections.abc import Iterator
|
|||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from scraper_kit.proxy_errors import NoProxyAvailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_prod(config: ScraperConfig | None) -> bool:
|
||||
"""True если config задан и `environment == "production"` (#2616 шаг 1).
|
||||
|
||||
`getattr` с дефолтом "dev" — старые/минимальные ScraperConfig-заглушки без поля
|
||||
`environment` (тесты, ещё не обновлённые фейки) трактуются как dev → поведение
|
||||
не меняется для них (безопасный дефолт, не прод-отказ по умолчанию).
|
||||
"""
|
||||
return config is not None and getattr(config, "environment", "dev") == "production"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def curl_proxy_url(
|
||||
config: ScraperConfig | None,
|
||||
|
|
@ -41,6 +58,11 @@ def curl_proxy_url(
|
|||
env_fallback_url: прокси-url как сейчас (config.cian_proxy_url / scraper_proxy_url).
|
||||
|
||||
Yields effective url (может быть None = прямое подключение, как и раньше).
|
||||
|
||||
Raises:
|
||||
NoProxyAvailableError: пул был реально задействован (use_pool=True), acquire()
|
||||
вернул None/упал, И окружение "production" (#2616 шаг 1) — env-прокси мертвы,
|
||||
падать на них молча нельзя. HTTP-запрос НЕ выполняется (raise до yield).
|
||||
"""
|
||||
use_pool = (
|
||||
config is not None
|
||||
|
|
@ -60,7 +82,16 @@ def curl_proxy_url(
|
|||
lease = None
|
||||
|
||||
if lease is None:
|
||||
# off / пул пуст / ошибка acquire → env как сейчас (сбор не ломаем).
|
||||
if use_pool and _is_prod(config):
|
||||
# Пул реально задействован (прод) и пуст/сломан — env-прокси мертвы (#2613),
|
||||
# НЕ идём на них молча. Явный отказ ДО HTTP-запроса (#2616 шаг 1).
|
||||
logger.warning(
|
||||
"proxy_pool: acquire(%s) empty in production — refusing (no HTTP request), "
|
||||
"NOT falling back to dead env proxy (#2616)",
|
||||
provider,
|
||||
)
|
||||
raise NoProxyAvailableError(provider)
|
||||
# off / dev-test / пул пуст в dev → env как сейчас (легитимный dev/no-op путь).
|
||||
yield env_fallback_url
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"""Общее исключение "нет доступного прокси" (#2616 шаг 1).
|
||||
|
||||
Раньше `proxy_pool.acquire()` вернувший `None` в прод-окружении означал молчаливый
|
||||
fallback на env-прокси (`AVITO_PROXY_URL`/`CIAN_PROXY_URL`/`YANDEX_PROXY_URL`/
|
||||
`BROWSER_PROXY_*`) — все они мертвы (подписку mobileproxy закрыли, проверено вживую
|
||||
#2613: 407/connection refused/invalid credentials). Итог — код либо шёл в HTTP-запрос
|
||||
через заведомо мёртвый узел, либо (browser-путь) camoufox лез напрямую с IP сервера.
|
||||
|
||||
`NoProxyAvailableError` — явный, распознаваемый маркер "у нас нет доступного прокси
|
||||
прямо сейчас" (наша инфраструктура), НЕ "площадка нас забанила" (`AvitoBlockedError`/
|
||||
`AvitoRateLimitedError` — площадка) и НЕ "сетевая ошибка" (`httpx`/`curl_cffi` transport-
|
||||
исключения). Поднимается ДО HTTP-запроса — ни один сетевой вызов не делается.
|
||||
|
||||
Подниматься должен ТОЛЬКО когда пул РЕАЛЬНО задействован (`use_pool=True`, то есть
|
||||
`ScraperConfig.use_proxy_pool_curl`/`use_proxy_pool_browser` включён И `ProxyProvider`
|
||||
передан) И окружение прод (`ScraperConfig.environment == "production"`). В dev/test/
|
||||
config=None — легитимный direct/env-fallback путь не трогаем (см. providers/cian/
|
||||
detail.py:118 "Пусто → прямое подключение (dev/no-op)").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class NoProxyAvailableError(RuntimeError):
|
||||
"""Пул прокси пуст (или acquire упал) в прод-окружении — отказ, не HTTP-запрос.
|
||||
|
||||
Отличается от `AvitoBlockedError`/`AvitoRateLimitedError` (блокировка площадкой)
|
||||
и от голых `httpx`/`curl_cffi` transport-исключений (сетевая ошибка) — это НАША
|
||||
инфраструктура (нет живого прокси), не внешний блок.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: str) -> None:
|
||||
self.provider = provider
|
||||
super().__init__(
|
||||
f"no proxy available for provider={provider!r} (pool empty in prod, "
|
||||
f"refusing env/direct fallback — #2616)"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NoProxyAvailableError"]
|
||||
Loading…
Add table
Reference in a new issue