fix(tradein/scraper): не ходить через мёртвый запасной прокси (#2634)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Successful in 38s
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 2m44s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 2m27s

Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
lekss361 2026-08-04 18:27:49 +00:00 committed by bot-backend
parent 966415dc8a
commit 7d13e93792
16 changed files with 766 additions and 13 deletions

View file

@ -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).

View file

@ -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 + пул пуст (acquireNone) тела без "proxy", НЕ падаем.
- use_pool=True + acquire бросил fallback без "proxy", НЕ падаем.
- use_pool=True + пул пуст (acquireNone) + dev (дефолт) тело без "proxy", НЕ падаем.
- use_pool=True + пул пуст (acquireNone) + 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) ───────

View file

@ -72,6 +72,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(
{
@ -79,6 +80,7 @@ def _make_recorder() -> tuple[type, list[dict[str, Any]]]:
"endpoint": endpoint,
"proxy_provider": proxy_provider,
"use_pool": use_pool,
"environment": environment,
}
)

View file

@ -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").'
)

View file

@ -3,10 +3,13 @@
Покрывает инвариант ship-dark + fallback на уровне helper'а `curl_proxy_url` и
class-based провайдера (YandexValuationScraper):
- флаг off / proxy_provider=None env-прокси, пул не трогается (golden-parity);
- флаг on + пул пуст (acquireNone) fallback env, не падаем;
- флаг on + lease fetch через lease.url, mark_health вызван, release в finally;
- флаг on + пул пуст (acquireNone) + dev fallback env, не падаем;
- флаг on + пул пуст (acquireNone) + 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 держится на всё время сессии ─────────────────

View file

@ -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:

View file

@ -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"

View file

@ -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: браузер мог не подняться (прокси лежал).

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -567,6 +567,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
@ -1045,6 +1048,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:
@ -1738,6 +1744,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:

View file

@ -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,
)

View file

@ -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

View file

@ -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"]