All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m39s
Deploy Trade-In / build-backend (push) Successful in 1m35s
Deploy Trade-In / deploy (push) Successful in 1m40s
642 lines
31 KiB
Python
642 lines
31 KiB
Python
"""Тесты browser-пула для kit `scraper_kit.browser_fetcher.BrowserFetcher` (#2164 P4).
|
||
|
||
Sticky-session fix (живая регрессия 2026-08): раньше `BrowserFetcher` брал/отпускал
|
||
lease из пула НА КАЖДЫЙ `/fetch` — при N>=2 живых узлах пула `acquire()` (ORDER BY
|
||
last_ok_at NULLS LAST — «давно не использованный первый») гарантированно выдавал
|
||
ДРУГОЙ прокси на каждый вызов, а tradein-browser релончит camoufox при каждой смене
|
||
желаемого прокси (см. server.py `_ensure_browser`) — 17 relaunch'ей за 15 минут в
|
||
проде. Фикс — ОДИН lease на весь жизненный цикл фетчера (см. __aenter__/__aexit__).
|
||
|
||
Инвариант ship-dark/fallback (не изменился):
|
||
- use_pool=False (дефолт) → в теле POST /fetch НЕТ поля "proxy"; proxy_provider не
|
||
трогается (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) + dev (дефолт) → тело без "proxy", НЕ падаем.
|
||
- use_pool=True + пул пуст (acquire→None) + prod (#2616 шаг 1) → NoProxyAvailableError
|
||
на initial acquire (__aenter__) ИЛИ на ре-acquire mid-run (осознанная ротация после
|
||
N подряд провалов) — POST /fetch НЕ отправляется вовсе (ни на входе, ни после отказа).
|
||
- use_pool=True + acquire бросил → fallback без "proxy" (dev), НЕ падаем.
|
||
- fetch кинул → mark_health(ok=False) + release всё равно (finally).
|
||
|
||
Новый инвариант (sticky session, #2164 sticky-session fix):
|
||
- acquire() вызывается РОВНО ОДИН раз — в __aenter__, а НЕ на каждый /fetch.
|
||
- Несколько /fetch подряд в одной сессии несут ОДИН и тот же lease.url.
|
||
- release() — один раз в __aexit__ (finally — lease не течёт даже при исключении
|
||
внутри `async with`-блока).
|
||
- mark_health + touch (heartbeat, продлевает leased_at — см. proxy_pool.touch) —
|
||
на КАЖДЫЙ /fetch (успешный и неуспешный), та же грануляция, что была раньше.
|
||
- N подряд неудачных /fetch (_LEASE_ROTATE_AFTER_FAILS) → lease ОСОЗНАННО меняется
|
||
один раз (release старого + acquire нового), счётчик обнуляется; успех сбрасывает
|
||
счётчик до этого порога.
|
||
|
||
httpx полностью замокан: fetcher._client подменяется MagicMock'ом после __aenter__.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
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
|
||
|
||
# _LEASE_ROTATE_AFTER_FAILS не существовал до sticky-session фикса (#2164, 2026-08) —
|
||
# импортируем с фолбэком, чтобы ImportError не ронял КОЛЛЕКЦИЮ всего модуля (маскируя
|
||
# value-based падения остальных тестов на pre-fix коде). Тесты, которым он реально
|
||
# нужен (rotate-after-N-fails), падают на своём собственном месте, если его нет.
|
||
try:
|
||
from scraper_kit.browser_fetcher import _LEASE_ROTATE_AFTER_FAILS
|
||
except ImportError:
|
||
_LEASE_ROTATE_AFTER_FAILS = 3 # dummy — тесты ниже провалятся по существу, не по импорту
|
||
|
||
|
||
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
|
||
"""httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json."""
|
||
resp = MagicMock()
|
||
if raise_exc is not None:
|
||
resp.raise_for_status.side_effect = raise_exc
|
||
else:
|
||
resp.raise_for_status.return_value = None
|
||
resp.json.return_value = json_payload
|
||
client = MagicMock()
|
||
client.post = AsyncMock(return_value=resp)
|
||
client.aclose = AsyncMock(return_value=None) # __aexit__ awaits это при закрытии сессии
|
||
return client
|
||
|
||
|
||
class _FakeProxyProvider:
|
||
"""ProxyProvider-заглушка: acquire отдаёт lease(ы) по очереди, считает вызовы."""
|
||
|
||
def __init__(
|
||
self,
|
||
leases: ProxyLease | None | list[ProxyLease | None],
|
||
*,
|
||
acquire_raises: bool = False,
|
||
) -> None:
|
||
self._queue: list[ProxyLease | None] = (
|
||
list(leases) if isinstance(leases, list) else [leases]
|
||
)
|
||
self._acquire_raises = acquire_raises
|
||
self.acquired: list[str] = []
|
||
self.released: list[int] = []
|
||
self.health: list[tuple[int, bool]] = []
|
||
self.touched: list[int] = []
|
||
self.banned: list[tuple[int, str]] = []
|
||
|
||
def acquire(self, provider: str) -> ProxyLease | None:
|
||
self.acquired.append(provider)
|
||
if self._acquire_raises:
|
||
raise RuntimeError("pool boom")
|
||
if self._queue:
|
||
return self._queue.pop(0)
|
||
return None
|
||
|
||
def release(self, lease: ProxyLease) -> None:
|
||
self.released.append(lease.id)
|
||
|
||
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
|
||
self.health.append((lease.id, ok))
|
||
|
||
def touch(self, lease: ProxyLease) -> None:
|
||
self.touched.append(lease.id)
|
||
|
||
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||
self.banned.append((lease.id, source))
|
||
|
||
|
||
async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
|
||
"""Реально входит в `__aenter__` (триггерит lease-acquire), потом подменяет httpx-клиент."""
|
||
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
|
||
await bf.__aenter__()
|
||
bf._client = client
|
||
return bf
|
||
|
||
|
||
# ── ship-dark / fallback parity (не изменилось) ───────────────────────────────
|
||
|
||
|
||
async def test_fetch_pool_off_no_proxy_in_body() -> None:
|
||
"""use_pool=False (дефолт) → тело без 'proxy', provider не трогается вообще (parity)."""
|
||
provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http"))
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False
|
||
|
||
html = await bf.fetch("https://avito.ru/x")
|
||
|
||
assert html == "<ok>"
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert "proxy" not in body
|
||
assert provider.acquired == [] # пул не трогали даже в __aenter__
|
||
|
||
|
||
async def test_empty_pool_falls_back_no_proxy_ever() -> None:
|
||
"""use_pool=True + пул пуст (acquire→None в __aenter__) → без 'proxy', НЕ падаем."""
|
||
provider = _FakeProxyProvider(None)
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
|
||
|
||
await bf.fetch("https://cian.ru/1")
|
||
await bf.fetch("https://cian.ru/2")
|
||
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert "proxy" not in body
|
||
assert provider.acquired == ["cian"] # одна попытка (в __aenter__), не на каждый fetch
|
||
assert provider.released == []
|
||
assert provider.health == []
|
||
assert provider.touched == []
|
||
|
||
|
||
async def test_acquire_error_in_aenter_falls_back() -> None:
|
||
"""acquire в __aenter__ бросил → fallback без 'proxy', сбор НЕ падает."""
|
||
provider = _FakeProxyProvider(None, acquire_raises=True)
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="yandex", proxy_provider=provider, use_pool=True)
|
||
|
||
html = await bf.fetch("https://yandex.ru/x")
|
||
|
||
assert html == "<ok>"
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert "proxy" not in body
|
||
assert provider.released == []
|
||
|
||
|
||
# ── sticky session: ОДИН lease на весь жизненный цикл (ключевой фикс) ─────────
|
||
|
||
|
||
async def test_acquire_happens_once_in_aenter_not_per_fetch() -> None:
|
||
"""acquire() вызывается РОВНО один раз при входе в сессию, до первого /fetch."""
|
||
lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<ok>"})
|
||
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
assert provider.acquired == ["avito"] # уже случилось в __aenter__
|
||
|
||
await bf.fetch("https://avito.ru/x")
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["proxy"] == "http://u:p@pool:8080"
|
||
assert body["proxy_kind"] == "http"
|
||
assert provider.acquired == ["avito"] # fetch НЕ вызвал acquire повторно
|
||
|
||
|
||
async def test_two_sequential_fetches_use_same_proxy() -> None:
|
||
"""Два /fetch подряд в одной сессии несут ОДИН и тот же lease.url (было: КАЖДЫЙ
|
||
/fetch мог получить ДРУГОЙ прокси — camoufox релончился на каждый запрос)."""
|
||
lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
await bf.fetch("https://avito.ru/1")
|
||
body1 = client.post.call_args.kwargs["json"]
|
||
await bf.fetch("https://avito.ru/2")
|
||
body2 = client.post.call_args.kwargs["json"]
|
||
|
||
assert body1["proxy"] == body2["proxy"] == "http://u:p@pool:8080"
|
||
assert provider.acquired == ["avito"] # ровно один acquire на всю сессию
|
||
|
||
|
||
async def test_lease_released_on_session_close() -> None:
|
||
"""release() случается один раз, в __aexit__ — не раньше (внутри сессии lease жив)."""
|
||
lease = ProxyLease(id=7, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<ok>"})
|
||
|
||
bf = BrowserFetcher(
|
||
endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True
|
||
)
|
||
async with bf:
|
||
bf._client = client
|
||
await bf.fetch("https://avito.ru/x")
|
||
assert provider.released == [] # ещё держим lease внутри сессии
|
||
|
||
assert provider.released == [7] # __aexit__ отпустил
|
||
|
||
|
||
async def test_lease_released_on_exception_inside_session() -> None:
|
||
"""Исключение внутри `async with`-блока (бизнес-код упал) → __aexit__ всё равно
|
||
вызывается Python'ом → lease освобождён, НЕ течёт."""
|
||
lease = ProxyLease(id=9, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<ok>"})
|
||
|
||
with pytest.raises(RuntimeError, match="business logic exploded"):
|
||
async with BrowserFetcher(
|
||
endpoint="http://browser:3000", source="avito", proxy_provider=provider, use_pool=True
|
||
) as bf:
|
||
bf._client = client
|
||
raise RuntimeError("business logic exploded mid-session")
|
||
|
||
assert provider.released == [9]
|
||
|
||
|
||
# ── mark_health / touch — на каждый /fetch (грануляция сохранена) ─────────────
|
||
|
||
|
||
async def test_mark_health_and_touch_called_per_fetch() -> None:
|
||
"""mark_health + touch(heartbeat) идут на КАЖДЫЙ /fetch — та же грануляция, что
|
||
была на per-fetch acquire/release раньше (не огрублена до «раз в сессию»)."""
|
||
lease = ProxyLease(id=5, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
await bf.fetch("https://avito.ru/1")
|
||
await bf.fetch("https://avito.ru/2")
|
||
await bf.fetch("https://avito.ru/3")
|
||
|
||
assert provider.health == [(5, True), (5, True), (5, True)]
|
||
assert provider.touched == [5, 5, 5]
|
||
|
||
|
||
async def test_fetch_error_marks_health_false_but_keeps_lease_for_session() -> None:
|
||
"""fetch кинул → mark_health(False) + touch, но lease НЕ освобождается сразу — он
|
||
держится до конца сессии (release только в __aexit__ или при осознанной ротации)."""
|
||
lease = ProxyLease(id=3, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
# raise_for_status кидает НЕ-httpx ошибку → fetch() не ретраит, пробрасывает наверх.
|
||
client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
assert provider.health == [(3, False)]
|
||
assert provider.touched == [3]
|
||
assert provider.released == [] # сессия ещё жива — lease держим
|
||
|
||
|
||
# ── осознанная ротация после N подряд провалов (не мечемся на каждый fetch) ───
|
||
|
||
|
||
async def test_lease_not_rotated_before_threshold() -> None:
|
||
"""N-1 подряд провалов — ЕЩЁ недостаточно для смены lease."""
|
||
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
|
||
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
|
||
provider = _FakeProxyProvider([lease1, lease2])
|
||
client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
assert provider.released == [] # ещё не порог
|
||
assert provider.acquired == ["avito"] # всё ещё только __aenter__-acquire
|
||
|
||
|
||
async def test_lease_rotates_once_after_consecutive_failures() -> None:
|
||
"""Ровно `_LEASE_ROTATE_AFTER_FAILS` подряд неудач → release+acquire РОВНО один
|
||
раз (не на каждый fetch). Следующий /fetch уже несёт НОВЫЙ прокси."""
|
||
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
|
||
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
|
||
provider = _FakeProxyProvider([lease1, lease2])
|
||
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
|
||
bf = await _fetcher(fail_client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
for _ in range(_LEASE_ROTATE_AFTER_FAILS):
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
assert provider.released == [1] # старый lease отпущен ровно один раз
|
||
assert provider.acquired == ["avito", "avito"] # __aenter__ + одна ротация — не N+1
|
||
|
||
ok_client = _mock_client({"html": "<ok>"})
|
||
bf._client = ok_client
|
||
await bf.fetch("https://avito.ru/y")
|
||
body = ok_client.post.call_args.kwargs["json"]
|
||
assert body["proxy"] == "http://pool2:8080" # уже НОВЫЙ lease
|
||
|
||
|
||
async def test_success_resets_fail_streak() -> None:
|
||
"""Успех между провалами сбрасывает счётчик — (N-1) провал + успех + (N-1) провал
|
||
НЕ должны суммироваться в ротацию (иначе редкие транзиентные ошибки на в целом
|
||
здоровом прокси гоняли бы ротацию так же часто, как раньше гоняли relaunch)."""
|
||
lease1 = ProxyLease(id=1, url="http://pool1:8080", kind="http")
|
||
lease2 = ProxyLease(id=2, url="http://pool2:8080", kind="http")
|
||
provider = _FakeProxyProvider([lease1, lease2])
|
||
ok_client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(ok_client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
|
||
bf._client = fail_client
|
||
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
bf._client = ok_client
|
||
await bf.fetch("https://avito.ru/y") # успех — сбрасывает streak
|
||
|
||
bf._client = fail_client
|
||
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/z")
|
||
|
||
assert provider.released == [] # порог так и не достигнут подряд
|
||
|
||
|
||
# ── fetch_json — тот же sticky-session инвариант ──────────────────────────────
|
||
|
||
|
||
async def test_fetch_json_pool_on_injects_proxy_from_session_lease() -> None:
|
||
lease = ProxyLease(id=9, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"status": 200, "body": "{}"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
await bf.fetch_json(
|
||
"https://avito.ru/api", method="POST", body="{}", origin="https://avito.ru/"
|
||
)
|
||
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["proxy"] == "http://pool:8080"
|
||
assert body["proxy_kind"] == "http"
|
||
assert provider.touched == [9]
|
||
assert provider.health == [(9, True)]
|
||
assert provider.released == [] # сессия ещё жива
|
||
|
||
|
||
async def test_fetch_json_pool_off_no_proxy() -> None:
|
||
"""use_pool=False → fetch_json без 'proxy' в теле (parity)."""
|
||
client = _mock_client({"status": 200, "body": "{}"})
|
||
bf = await _fetcher(client, source="avito")
|
||
|
||
await bf.fetch_json("https://avito.ru/api")
|
||
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert "proxy" not in body
|
||
|
||
|
||
# ── #2616 шаг 1: пул пуст в prod → отказ, НЕ мёртвый env-фолбэк ────────────────
|
||
#
|
||
# Sticky-session fix переместил acquire с "на каждый /fetch" на "__aenter__ (+ ре-acquire
|
||
# на осознанной ротации после N подряд провалов)" — guard живёт в _acquire_lease(), общей
|
||
# для ОБОИХ call-site'ов, так что отказ теперь может случиться либо при входе в сессию
|
||
# (_fetcher/__aenter__), либо mid-run (внутри fetch(), при ротации).
|
||
|
||
|
||
async def test_fetch_pool_empty_dev_falls_back_no_proxy() -> None:
|
||
"""Пул пуст + dev (дефолт environment) → прежнее поведение: тело без 'proxy'."""
|
||
provider = _FakeProxyProvider(None)
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _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_on_initial_acquire() -> None:
|
||
"""Пул пуст + prod на INITIAL acquire (__aenter__) → NoProxyAvailableError, POST
|
||
/fetch НЕ отправляется вовсе — отказ случается ДО входа в сессию.
|
||
|
||
client.post настроен падать AssertionError на ЛЮБОМ вызове — если бы код тихо
|
||
зафолбэчился (регрессия), тест упал бы с несовпадающим типом исключения, а не
|
||
просто "прошёл иначе" (falsifiable).
|
||
"""
|
||
provider = _FakeProxyProvider(None)
|
||
client = MagicMock()
|
||
client.post = AsyncMock(side_effect=AssertionError("POST /fetch must NOT happen"))
|
||
|
||
with pytest.raises(NoProxyAvailableError):
|
||
await _fetcher(
|
||
client,
|
||
source="avito",
|
||
proxy_provider=provider,
|
||
use_pool=True,
|
||
environment="production",
|
||
)
|
||
|
||
client.post.assert_not_called()
|
||
assert provider.acquired == ["avito"]
|
||
assert provider.released == []
|
||
assert provider.health == []
|
||
|
||
|
||
async def test_fetch_json_pool_empty_prod_refuses_on_initial_acquire() -> None:
|
||
"""Та же гарантия для fetch_json-путей: prod + пул пуст на __aenter__ → отказ, без
|
||
POST (guard общий для fetch/fetch_json — обе идут через один и тот же lease)."""
|
||
provider = _FakeProxyProvider(None)
|
||
client = MagicMock()
|
||
client.post = AsyncMock(side_effect=AssertionError("POST /fetch-json must NOT happen"))
|
||
|
||
with pytest.raises(NoProxyAvailableError):
|
||
await _fetcher(
|
||
client,
|
||
source="cian",
|
||
proxy_provider=provider,
|
||
use_pool=True,
|
||
environment="production",
|
||
)
|
||
|
||
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 = await _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 == [] # сессия ещё жива — release только в __aexit__
|
||
|
||
|
||
async def test_fetch_pool_empties_mid_run_prod_refuses_on_rotate_reacquire() -> None:
|
||
"""Пул опустел НА РЕ-ACQUIRE mid-run (осознанная ротация после N подряд провалов)
|
||
в prod → тот же отказ NoProxyAvailableError, а НЕ тихий переход на без-proxy.
|
||
|
||
Единственный lease выдаётся на initial acquire; на N-й подряд провал ротация
|
||
release()'ит его и повторно зовёт acquire() — очередь уже пуста → guard в
|
||
_acquire_lease() поднимает NoProxyAvailableError вместо возврата None. Он
|
||
вытесняет исходный ValueError (chained via __context__, не проглочен).
|
||
"""
|
||
lease = ProxyLease(id=1, url="http://pool1:8080", kind="http")
|
||
provider = _FakeProxyProvider([lease]) # ре-acquire после ротации застанет пустой пул
|
||
fail_client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
|
||
bf = await _fetcher(
|
||
fail_client,
|
||
source="avito",
|
||
proxy_provider=provider,
|
||
use_pool=True,
|
||
environment="production",
|
||
)
|
||
|
||
for _ in range(_LEASE_ROTATE_AFTER_FAILS - 1):
|
||
with pytest.raises(ValueError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
# N-й подряд провал: ValueError → _report_fetch_result(False) → порог достигнут →
|
||
# release(1) + re-acquire → пул пуст + prod → NoProxyAvailableError вместо ValueError.
|
||
with pytest.raises(NoProxyAvailableError):
|
||
await bf.fetch("https://avito.ru/x")
|
||
|
||
assert provider.released == [1] # старый lease отпущен ровно один раз
|
||
assert provider.acquired == ["avito", "avito"] # __aenter__ + одна попытка ротации
|
||
assert bf._lease is None # #2616: не залипает на уже отпущенном lease
|
||
|
||
|
||
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) ───────
|
||
|
||
|
||
async def test_fetch_without_origin_sends_none() -> None:
|
||
"""fetch(url) без origin → payload содержит "origin": None (parity avito/cian/yandex).
|
||
|
||
Ни один провайдер кроме domclick не передаёт origin — сервер (body.get("origin"))
|
||
трактует None как «origin-goto не выполнять», поведение не меняется.
|
||
"""
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito")
|
||
|
||
html = await bf.fetch("https://avito.ru/x")
|
||
|
||
assert html == "<ok>"
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["origin"] is None
|
||
|
||
|
||
async def test_fetch_with_origin_sends_it_in_payload() -> None:
|
||
"""fetch(url, origin=X) → payload несёт origin=X (domclick vtorichka-SERP anchor)."""
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="cian")
|
||
|
||
await bf.fetch(
|
||
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
|
||
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||
)
|
||
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["origin"] == "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
|
||
|
||
|
||
# ── fetch() cookies passthrough (DomClick cookie-injection, #2000) ───────────────
|
||
|
||
|
||
async def test_fetch_without_cookies_sends_none() -> None:
|
||
"""fetch(url) без cookies → payload содержит "cookies": None (parity avito/cian/yandex).
|
||
|
||
Ни один провайдер кроме domclick debug-fetch не передаёт cookies — сервер
|
||
(body.get("cookies")) трактует None как «инъекции не делать», поведение не меняется.
|
||
"""
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito")
|
||
|
||
html = await bf.fetch("https://avito.ru/x")
|
||
|
||
assert html == "<ok>"
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["cookies"] is None
|
||
|
||
|
||
async def test_fetch_with_cookies_sends_them_in_payload() -> None:
|
||
"""fetch(url, cookies=X) → payload несёт cookies=X (DomClick session cookie-injection)."""
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="cian")
|
||
session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"}
|
||
|
||
await bf.fetch(
|
||
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
|
||
cookies=session_cookies,
|
||
)
|
||
|
||
body = client.post.call_args.kwargs["json"]
|
||
assert body["cookies"] == session_cookies
|
||
|
||
|
||
# ── report_ban — довести сигнал бана до пула (#2600 п.1) ──────────────────────
|
||
#
|
||
# Root cause: fetch() успешно вернул HTML (HTTP 200) — уже отчитался
|
||
# mark_health(ok=True). Бан-заглушка распознаётся ПОЗЖЕ, при разборе содержимого,
|
||
# провайдерским кодом (avito serp.py / cian _extract_state_tracked / yandex gate /
|
||
# domclick QRATOR-маркеры). report_ban — публичный способ явно переопределить тот
|
||
# ошибочный ok=True сигнал: помечает ТЕКУЩИЙ lease забаненным в пуле, пока lease
|
||
# ещё жив (до release/__aexit__).
|
||
|
||
|
||
async def test_report_ban_calls_provider_mark_banned_with_current_lease() -> None:
|
||
lease = ProxyLease(id=7, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<blocked>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
bf.report_ban("content-block suspected")
|
||
|
||
assert provider.banned == [(7, "avito")]
|
||
|
||
|
||
async def test_report_ban_noop_without_lease() -> None:
|
||
"""use_pool=False (дефолт) → lease нет → report_ban не трогает provider вообще."""
|
||
provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http"))
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False
|
||
|
||
bf.report_ban("should be no-op")
|
||
|
||
assert provider.banned == []
|
||
|
||
|
||
async def test_report_ban_noop_without_provider() -> None:
|
||
"""proxy_provider=None (дефолт) → report_ban не падает (best-effort)."""
|
||
client = _mock_client({"html": "<ok>"})
|
||
bf = await _fetcher(client, source="avito")
|
||
|
||
bf.report_ban("no provider configured") # не должно бросить
|
||
|
||
|
||
async def test_report_ban_survives_provider_exception() -> None:
|
||
"""mark_banned упал внутри провайдера — report_ban не пробрасывает (best-effort,
|
||
как touch/mark_health/release — проблема пула не должна ронять сбор)."""
|
||
|
||
class _BoomProvider(_FakeProxyProvider):
|
||
def mark_banned(self, lease: ProxyLease, *, source: str) -> None:
|
||
raise RuntimeError("pool db down")
|
||
|
||
lease = ProxyLease(id=3, url="http://pool:8080", kind="http")
|
||
provider = _BoomProvider(lease)
|
||
client = _mock_client({"html": "<blocked>"})
|
||
bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
|
||
|
||
bf.report_ban("captcha") # не должно бросить
|
||
|
||
|
||
async def test_report_ban_does_not_release_or_rotate_lease() -> None:
|
||
"""report_ban только сообщает пулу — lease НЕ release'ится и НЕ ротируется здесь
|
||
(это забота caller'а: обычно исключение пробрасывается наверх и __aexit__
|
||
релизит lease как обычно)."""
|
||
lease = ProxyLease(id=5, url="http://pool:8080", kind="http")
|
||
provider = _FakeProxyProvider(lease)
|
||
client = _mock_client({"html": "<blocked>"})
|
||
bf = await _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
|
||
|
||
bf.report_ban("content-block suspected")
|
||
|
||
assert provider.released == []
|
||
assert bf._lease is not None
|
||
assert bf._lease.id == 5
|