From 2fc4b1b0fcd874c394395e21ebdb6104b1edf9ae Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 16:56:23 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/scraper):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=20=E2=80=94=20=D1=8D=D1=82=D0=BE=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=B0=D0=BD,=20=D0=B0=20=D0=BD=D0=B0=D1=88?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scraper_adapters.py | 5 + .../test_kit_browser_fetcher_proxy_pool.py | 93 +++++- .../backend/tests/test_kit_serp_proxy_pool.py | 2 + .../tests/test_proxy_pool_curl_paths.py | 93 +++++- .../tests/test_scraper_adapters_contracts.py | 2 + .../tests/test_scraper_kit_providers_base.py | 24 ++ tradein-mvp/browser/server.py | 64 ++++ .../browser/test_server_no_proxy_refusal.py | 310 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 36 +- .../scraper-kit/src/scraper_kit/contracts.py | 6 + .../src/scraper_kit/providers/_base.py | 8 + .../src/scraper_kit/providers/_proxy.py | 37 ++- .../src/scraper_kit/proxy_errors.py | 40 +++ 13 files changed, 707 insertions(+), 13 deletions(-) create mode 100644 tradein-mvp/browser/test_server_no_proxy_refusal.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 2d2d9b4a..e259ba97 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -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). diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py index 41627db1..6b18ca63 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py @@ -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": ""}) + bf = _fetcher(client, source="cian", proxy_provider=provider, use_pool=True) + + html = await bf.fetch("https://cian.ru/x") + + assert html == "" + 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": ""}) + bf = _fetcher( + client, source="avito", proxy_provider=provider, use_pool=True, environment="production" + ) + + html = await bf.fetch("https://avito.ru/x") + + assert html == "" + 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) ─────── diff --git a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py index 8352d77d..e249474b 100644 --- a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py @@ -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, } ) diff --git a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py index 3d9f3405..a66d0e1f 100644 --- a/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py +++ b/tradein-mvp/backend/tests/test_proxy_pool_curl_paths.py @@ -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 держится на всё время сессии ───────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py index 105f9e78..1ae9dbef 100644 --- a/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py +++ b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py index 532090ba..7fb52207 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py @@ -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" diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index d03b95f0..7fcec8f4 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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: браузер мог не подняться (прокси лежал). diff --git a/tradein-mvp/browser/test_server_no_proxy_refusal.py b/tradein-mvp/browser/test_server_no_proxy_refusal.py new file mode 100644 index 00000000..2d14aefb --- /dev/null +++ b/tradein-mvp/browser/test_server_no_proxy_refusal.py @@ -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 "" + + 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 "" + + 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 "" + + 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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 16032a00..ecc1ae8c 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py index e7e1cc11..88152da8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index c39b79b8..422998b9 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -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, ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py index 5556a17b..01af1841 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py new file mode 100644 index 00000000..54734c05 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py @@ -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"] -- 2.45.3 From d4e4dd712428d63e93d8f22b0916299778744ced Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 4 Aug 2026 23:23:42 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/scraper):=20=D0=B4=D0=BE=D0=B6?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20guard=20#2616=20=D0=B4=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4-=D0=BF=D1=83=D1=82=D0=B5=D0=B9=20(=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью нашло: guard NoProxyAvailableError был мёртв ровно там, где нужен — (1) avito-пути pipeline.py конструируют BrowserFetcher напрямую, минуя build_browser_fetcher(), и не прокидывали environment → дефолт dev, отказ никогда не срабатывал; добавлен environment=getattr(config, ...) во все 3 construction-site + source-level тест-гард на новые сайты; (2) browser-сайдкар: ENVIRONMENT не задан в docker-compose.prod.yml → IS_PROD guard server.py читал dev; добавлен ENVIRONMENT: production в environment-блок. --- ...est_pipeline_browserfetcher_environment.py | 46 +++++++++++++++++++ tradein-mvp/docker-compose.prod.yml | 4 ++ .../src/scraper_kit/orchestration/pipeline.py | 9 ++++ 3 files changed, 59 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py diff --git a/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py b/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py new file mode 100644 index 00000000..ae5fefe1 --- /dev/null +++ b/tradein-mvp/backend/tests/test_pipeline_browserfetcher_environment.py @@ -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").' + ) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 4d346c26..bb1e073d 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index beb03b03..a23113ad 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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: -- 2.45.3