feat(tradein-browser): additive POST /fetch-json + BrowserFetcher.fetch_json (#915 Stage 2) (#1915)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / build-browser (push) Successful in 2m53s
Deploy Trade-In / deploy (push) Successful in 1m30s

This commit is contained in:
bot-backend 2026-06-26 09:30:35 +00:00
parent e0cd378d19
commit d479edeb3f
4 changed files with 645 additions and 3 deletions

View file

@ -94,6 +94,38 @@ class BrowserFetcher:
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(url)
async def fetch_json(
self,
url: str,
*,
method: str = "GET",
headers: dict[str, str] | None = None,
body: str | None = None,
origin: str | None = None,
) -> dict:
"""In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}.
body уже сериализованная строка (caller делает json.dumps для POST).
origin same-origin страница, на которую перейдёт камуфокс перед fetch.
При HTTPError / TransportError делает одну повторную попытку после короткой
паузы. Остальные исключения всплывают к вызывающему коду.
"""
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
try:
return await self._post_fetch_json(url, method, headers, body, origin)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s",
type(exc).__name__,
_RETRY_SLEEP_S,
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch_json(url, method, headers, body, origin)
async def login(
self,
*,
@ -159,6 +191,39 @@ class BrowserFetcher:
logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html))
return html
async def _post_fetch_json(
self,
url: str,
method: str,
headers: dict[str, str] | None,
body: str | None,
origin: str | None,
) -> dict:
"""Один HTTP POST к /fetch-json эндпоинту сервиса."""
assert self._client is not None
assert self._endpoint is not None
resp = await self._client.post(
f"{self._endpoint}/fetch-json",
json={
"url": url,
"source": self._source,
"method": method,
"headers": headers or {},
"body": body,
"origin": origin,
},
)
resp.raise_for_status()
data: dict = resp.json()
logger.debug(
"BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d",
url,
data.get("status"),
len(data.get("body") or ""),
)
return data
async def _post_login(self, body: dict[str, object]) -> dict[str, str]:
"""Один HTTP POST к /login эндпоинту сервиса."""
assert self._client is not None

View file

@ -40,6 +40,15 @@ def _make_ok_response(html: str = "<html>ok</html>") -> MagicMock:
return resp
def _make_ok_json_response(status: int = 200, body: str = "{}") -> MagicMock:
"""Создаёт мок httpx.Response с JSON {"status": ..., "body": ...} (для /fetch-json)."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.json = MagicMock(return_value={"status": status, "body": body})
resp.raise_for_status = MagicMock() # нет исключения
return resp
def _make_error_response(status: int = 500) -> MagicMock:
"""Создаёт мок httpx.Response который кидает HTTPStatusError при raise_for_status."""
resp = MagicMock(spec=httpx.Response)
@ -135,6 +144,139 @@ async def test_fetch_posts_source_field_when_set() -> None:
)
# ── fetch_json(): in-page fetch через /fetch-json (#915 Stage 2) ───────────────
@pytest.mark.asyncio
async def test_fetch_json_returns_dict_from_json_response() -> None:
"""fetch_json() возвращает {"status","body"} dict из 200-ответа /fetch-json."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
ok_resp = _make_ok_json_response(status=200, body='{"items": []}')
ms = _mock_settings()
with patch("app.core.config.settings", ms):
fetcher = BrowserFetcher()
async with fetcher:
assert fetcher._client is not None
fetcher._client.post = AsyncMock(return_value=ok_resp) # type: ignore[method-assign]
result = await fetcher.fetch_json("https://avito.ru/api/x")
assert result == {"status": 200, "body": '{"items": []}'}
@pytest.mark.asyncio
async def test_fetch_json_posts_full_payload() -> None:
"""fetch_json() POST'ит url/source/method/headers/body/origin к /fetch-json."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
endpoint = "http://fake-browser:3000"
ok_resp = _make_ok_json_response()
ms = _mock_settings(browser_http_endpoint=endpoint)
with patch("app.core.config.settings", ms):
fetcher = BrowserFetcher(source="avito")
async with fetcher:
assert fetcher._client is not None
post_mock = AsyncMock(return_value=ok_resp)
fetcher._client.post = post_mock # type: ignore[method-assign]
await fetcher.fetch_json(
"https://avito.ru/api/listings",
method="POST",
headers={"content-type": "application/json"},
body='{"q": 1}',
origin="https://avito.ru/moskva/kvartiry",
)
post_mock.assert_called_once_with(
f"{endpoint}/fetch-json",
json={
"url": "https://avito.ru/api/listings",
"source": "avito",
"method": "POST",
"headers": {"content-type": "application/json"},
"body": '{"q": 1}',
"origin": "https://avito.ru/moskva/kvartiry",
},
)
@pytest.mark.asyncio
async def test_fetch_json_defaults_get_empty_headers_none_body() -> None:
"""Дефолты: method=GET, headers={}, body=None, origin=None."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
endpoint = "http://fake-browser:3000"
ok_resp = _make_ok_json_response()
ms = _mock_settings(browser_http_endpoint=endpoint)
with patch("app.core.config.settings", ms):
fetcher = BrowserFetcher(source="cian")
async with fetcher:
assert fetcher._client is not None
post_mock = AsyncMock(return_value=ok_resp)
fetcher._client.post = post_mock # type: ignore[method-assign]
await fetcher.fetch_json("https://cian.ru/api/x")
post_mock.assert_called_once_with(
f"{endpoint}/fetch-json",
json={
"url": "https://cian.ru/api/x",
"source": "cian",
"method": "GET",
"headers": {},
"body": None,
"origin": None,
},
)
@pytest.mark.asyncio
async def test_fetch_json_retries_once_on_transport_error() -> None:
"""TransportError первый раз → один retry → успешный второй вызов."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
ok_resp = _make_ok_json_response(status=200, body="retry-ok")
connect_err = httpx.ConnectError("Connection refused")
ms = _mock_settings()
with patch("app.core.config.settings", ms):
fetcher = BrowserFetcher()
async with fetcher:
assert fetcher._client is not None
post_mock = AsyncMock(side_effect=[connect_err, ok_resp])
fetcher._client.post = post_mock # type: ignore[method-assign]
with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()):
result = await fetcher.fetch_json("https://avito.ru/api/retry")
assert result == {"status": 200, "body": "retry-ok"}
assert post_mock.await_count == 2
@pytest.mark.asyncio
async def test_fetch_json_raises_after_two_http_errors() -> None:
"""Оба вызова /fetch-json кидают HTTPError → исключение пробрасывается."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
error_resp_1 = _make_error_response(500)
error_resp_2 = _make_error_response(500)
ms = _mock_settings()
with patch("app.core.config.settings", ms):
fetcher = BrowserFetcher()
async with fetcher:
assert fetcher._client is not None
post_mock = AsyncMock(side_effect=[error_resp_1, error_resp_2])
fetcher._client.post = post_mock # type: ignore[method-assign]
with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()):
with pytest.raises(httpx.HTTPStatusError):
await fetcher.fetch_json("https://avito.ru/api/fail")
assert post_mock.await_count == 2
# ── retry при HTTPError ────────────────────────────────────────────────────────

View file

@ -4,9 +4,10 @@
Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и
экспонирует простой HTTP API на базе aiohttp:
GET /health {"status": "ok", "browsers": {"avito": bool, ...}}
POST /fetch {"url": "..."} {"html": "..."}
POST /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]}
GET /health {"status": "ok", "browsers": {"avito": bool, ...}}
POST /fetch {"url": "..."} {"html": "..."}
POST /fetch-json {"url","method","headers","body","origin"} {"status","body"}
POST /login {"url": "...", "email": "...", "password": "...", ...} {"cookies": [...]}
Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright
WS-сервер) несовместим с современными версиями playwright (1.451.60):
@ -564,6 +565,70 @@ async def fetch_handler(request: web.Request) -> web.Response:
return web.json_response({"html": html})
async def fetch_json_handler(request: web.Request) -> web.Response:
"""POST /fetch-json {"url","method","headers","body","origin","source"} → {"status","body"}
Выполняет in-page ``fetch()`` ИЗ ТЁПЛОЙ страницы браузера поставщика: камуфокс
сначала переходит на ``origin`` (same-origin якорь), затем дёргает ``fetch(url)``
с реальным фингерпринтом + контекст-cookies этого инстанса + per-provider прокси.
Возвращает JSON/текст ответа как ``{"status": int, "body": str}``.
Чисто аддитивный путь (#915 Stage 2) — никто из прод-флоу пока не вызывает.
Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL или
явному body["provider"]/["source"]). Берётся ТОЛЬКО лок этого поставщика
разные поставщики работают параллельно, внутри поставщика строго 1 операция.
"""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON body"}, status=400)
url: str | None = body.get("url")
if not url:
return web.json_response({"error": "missing 'url' field"}, status=400)
method: str = body.get("method") or "GET"
headers: dict = body.get("headers") or {}
req_body = body.get("body") # None | str (caller сериализует через json.dumps)
# origin — same-origin страница, на которую перейдёт камуфокс перед fetch'ем.
# Явный body["origin"] имеет приоритет; иначе выводим f"{scheme}://{host}/" из url.
origin: str | None = body.get("origin")
if not origin:
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}/"
provider = _resolve_provider(body, url)
lock = await _lock_for(provider)
async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси/браузера не фетчим.
if not await _ensure_browser(provider):
logger.warning(
"tradein-browser[%s]: /fetch-json 503 — браузер недоступен (proxy may be down)",
provider,
)
return web.json_response(
{"error": "browser unavailable (proxy may be down)"}, status=503
)
try:
result = await _do_fetch_json(
provider, url, method=method, headers=headers, body=req_body, origin=origin
)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch-json error url=%r: %s: %s",
provider,
url,
type(exc).__name__,
exc,
)
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"status": result["status"], "body": result["body"]})
async def _apply_resource_block(page: object) -> None:
"""Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media).
@ -685,6 +750,107 @@ async def _fetch_once(provider: str, url: str) -> str:
return html
async def _do_fetch_json(
provider: str,
url: str,
*,
method: str,
headers: dict,
body: object,
origin: str,
) -> dict:
"""Одна попытка in-page fetch'а; при краше браузера — relaunch и один retry.
Зеркалит _do_fetch: caller держит _locks[provider] (нет параллельных страниц на
этом инстансе), поэтому relaunch безопасен никого не рвёт из-под живых страниц.
"""
try:
return await _fetch_json_once(
provider, url, method=method, headers=headers, body=body, origin=origin
)
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
"tradein-browser[%s]: краш браузера (%s), перезапуск + retry fetch-json: %s",
provider,
type(exc).__name__,
url,
)
await _relaunch_browser(provider)
if _browsers.get(provider) is None:
raise
return await _fetch_json_once(
provider, url, method=method, headers=headers, body=body, origin=origin
)
raise
async def _fetch_json_once(
provider: str,
url: str,
*,
method: str,
headers: dict,
body: object,
origin: str,
) -> dict:
"""Переходит на origin (same-origin якорь) и выполняет in-page fetch(url).
Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin``
(а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies +
реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся recycle через _relaunch_browser безопасен прямо здесь.
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
await _apply_resource_block(page)
await _pace_provider(provider)
await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined]
# БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies +
# same-origin scope для fetch), а не отрендеренные listings. Короткой паузы
# хватает, чтобы страница инициализировалась перед in-page fetch'ем.
await page.wait_for_timeout(500) # type: ignore[attr-defined]
result: dict = await page.evaluate( # type: ignore[attr-defined]
"""async ({url, method, headers, body}) => {
const resp = await fetch(url, {
method: method || 'GET',
headers: headers || {},
body: (body !== null && body !== undefined) ? body : undefined,
credentials: 'include',
});
const text = await resp.text();
return { status: resp.status, body: text };
}""",
{"url": url, "method": method, "headers": headers or {}, "body": body},
)
finally:
await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug(
"tradein-browser[%s]: fetch-json OK url=%r status=%s pages_since_launch=%d",
provider,
url,
result.get("status"),
_page_counters[provider],
)
if _page_counters[provider] >= BROWSER_RECYCLE_PAGES:
logger.info(
"tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера",
provider,
BROWSER_RECYCLE_PAGES,
)
await _relaunch_browser(provider)
return result
def _is_browser_crash(exc: BaseException) -> bool:
"""Проверяет является ли исключение признаком краша / разрыва браузера."""
cls_name = type(exc).__name__
@ -975,6 +1141,7 @@ def build_app() -> web.Application:
app.on_cleanup.append(_on_cleanup)
app.router.add_get("/health", health_handler)
app.router.add_post("/fetch", fetch_handler)
app.router.add_post("/fetch-json", fetch_json_handler)
app.router.add_post("/login", login_handler)
app.router.add_get("/pacing", pacing_get_handler)
app.router.add_put("/pacing", pacing_put_handler)

View file

@ -0,0 +1,268 @@
"""test_server_fetch_json.py — юниты для аддитивного /fetch-json (#915 Stage 2).
Проверяет новый in-page fetch путь, НЕ трогая существующий /fetch:
1. fetch_json_handler возвращает {"status","body"} из page.evaluate;
2. 400 при отсутствии url;
3. 503 когда _ensure_browser отдаёт False (прокси/браузер недоступны);
4. навигация идёт на origin (same-origin якорь), не на url;
5. page закрывается после in-page fetch (finally).
camoufox НЕ запускается: _ensure_browser мокается / провайдер-браузер подделан
фейковой page с AsyncMock на evaluate. Хендлер вызывается напрямую
(make_mocked_request не поднимает сокет) как в test_server_smoke.py.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_fetch_json.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock
import pytest
from aiohttp.test_utils import make_mocked_request
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
def _json_body(response: Any) -> dict[str, Any]:
"""Достаёт JSON-тело из aiohttp Response (body — bytes после json_response)."""
return json.loads(response.body.decode())
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние + инициализированный _locks_guard на каждый тест."""
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, "_locks_guard", asyncio.Lock())
async def _coro(value: Any) -> Any:
"""Хелпер: оборачивает значение в awaitable для подмены request.json()."""
return value
# ── фейковые page/browser с AsyncMock на evaluate ─────────────────────────────────
class _FakePage:
"""Поддельная page: фиксирует goto-цель, отдаёт фиктивный результат evaluate."""
def __init__(self, evaluate_result: dict[str, Any]) -> None:
self.goto_urls: list[str] = []
self.closed = 0
# evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы.
self.evaluate = AsyncMock(return_value=evaluate_result)
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
return None
async def close(self) -> None:
self.closed += 1
class _FakeBrowser:
def __init__(self, page: _FakePage) -> None:
self._page = page
self.opened = 0
async def new_page(self) -> _FakePage:
self.opened += 1
return self._page
def _make_request(body: dict[str, Any]) -> Any:
request = make_mocked_request("POST", "/fetch-json")
request.json = lambda: _coro(body) # type: ignore[method-assign]
return request
# ── happy path ────────────────────────────────────────────────────────────────────
def test_fetch_json_returns_evaluate_result(monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch_json_handler возвращает {"status","body"} ровно как отдал page.evaluate."""
page = _FakePage({"status": 200, "body": '{"ok": true}'})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
response = asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
)
)
assert response.status == 200
body = _json_body(response)
assert body == {"status": 200, "body": '{"ok": true}'}
# page.evaluate был вызван ровно один раз с url+method+headers+body.
page.evaluate.assert_awaited_once()
call_args = page.evaluate.await_args
assert call_args.args[1]["url"] == "https://www.avito.ru/api/x"
assert call_args.args[1]["method"] == "GET"
# Страница открылась и закрылась.
assert page.closed == 1
def test_fetch_json_navigates_to_origin_not_url(monkeypatch: pytest.MonkeyPatch) -> None:
"""Навигация идёт на origin (same-origin якорь), а fetch — уже на url."""
page = _FakePage({"status": 200, "body": "ok"})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/listings?q=1", "source": "avito"})
)
)
# Дефолтный origin выводится из url: scheme://netloc/.
assert page.goto_urls == ["https://www.avito.ru/"]
def test_fetch_json_uses_explicit_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""Явный body["origin"] перебивает выведенный из url."""
page = _FakePage({"status": 200, "body": "ok"})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(
server.fetch_json_handler(
_make_request(
{
"url": "https://www.avito.ru/api/x",
"origin": "https://www.avito.ru/moskva/kvartiry",
"source": "avito",
}
)
)
)
assert page.goto_urls == ["https://www.avito.ru/moskva/kvartiry"]
def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""method/headers/body прокидываются в page.evaluate без искажений."""
page = _FakePage({"status": 201, "body": "{}"})
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
asyncio.run(
server.fetch_json_handler(
_make_request(
{
"url": "https://www.avito.ru/api/x",
"method": "POST",
"headers": {"content-type": "application/json"},
"body": '{"a": 1}',
"source": "avito",
}
)
)
)
payload = page.evaluate.await_args.args[1]
assert payload["method"] == "POST"
assert payload["headers"] == {"content-type": "application/json"}
assert payload["body"] == '{"a": 1}'
# ── валидация / resilience ────────────────────────────────────────────────────────
def test_fetch_json_400_on_missing_url() -> None:
"""Отсутствие url → 400 (без обращения к браузеру)."""
response = asyncio.run(server.fetch_json_handler(_make_request({"source": "avito"})))
assert response.status == 400
assert "missing 'url'" in _json_body(response)["error"]
def test_fetch_json_400_on_invalid_json() -> None:
"""Невалидное JSON-тело → 400."""
request = make_mocked_request("POST", "/fetch-json")
async def _raise() -> Any:
raise ValueError("bad json")
request.json = _raise # type: ignore[method-assign]
response = asyncio.run(server.fetch_json_handler(request))
assert response.status == 400
assert "invalid JSON body" in _json_body(response)["error"]
def test_fetch_json_503_when_browser_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
"""/fetch-json отдаёт 503 (не 500/краш) когда инстанс поставщика не поднят."""
async def _no_ensure(provider: str) -> bool:
return False # прокси всё ещё недоступен → браузер остаётся None
monkeypatch.setattr(server, "_ensure_browser", _no_ensure)
response = asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
)
)
assert response.status == 503
assert "browser unavailable" in _json_body(response)["error"]
def test_fetch_json_500_on_evaluate_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ошибка page.evaluate (не краш браузера) → 500 с типом исключения."""
page = _FakePage({"status": 200, "body": "ok"})
page.evaluate = AsyncMock(side_effect=RuntimeError("eval boom"))
server._browsers["avito"] = _FakeBrowser(page)
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
async def _ensure(provider: str) -> bool:
return True
monkeypatch.setattr(server, "_ensure_browser", _ensure)
response = asyncio.run(
server.fetch_json_handler(
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
)
)
assert response.status == 500
assert "RuntimeError" in _json_body(response)["error"]
# Даже при ошибке evaluate страница должна закрыться (finally).
assert page.closed == 1