All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
`_fetch_json_once` делает goto(origin, domcontentloaded) → фиксированный settle → page.evaluate. Если страница за это время уходит в клиентскую навигацию, playwright рвёт JS-контекст и бросает «Execution context was destroyed». Такая ошибка не попадала ни в одну ветку восстановления: `_is_browser_crash` матчит только закрытие цели/браузера/соединения — значит для сайдкара это окончательный отказ, 500 наверх. Правка: 1. `_is_page_context_lost` — отдельный признак, не смешанный с крашем браузера. Маркер взят из САМОГО playwright (driver 1.60.0, coreBundle.js в образе сайдкара), а не из строки в нашем логе: обе формы сообщения — короткая и «..., most likely because of a navigation.» — имеют общий префикс. 2. Повтор на СВЕЖЕЙ странице без `_relaunch_browser`: браузер жив, умерла одна страница. Relaunch стоил бы ~10-20с и тёплый профиль инстанса (cookies, фингерпринт) ради здорового браузера. 3. Повтор ждёт `load` (FETCH_JSON_LOAD_WAIT_MS, best-effort). Без этого повтор попадает ровно в то же окно навигации: в логе один и тот же адрес падал дважды подряд, то есть гонка воспроизводится, а не «рассасывается». Happy-path не удлиняется — ожидание только на ретрае. Ретрай строго один и только по этому признаку: чужая ошибка (напр. ERR_PROXY_CONNECTION_FAILED) уезжает наверх сразу — повтор невосстановимого стоит полной ре-навигации на каждый дом. Refs #2676
543 lines
22 KiB
Python
543 lines
22 KiB
Python
"""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.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917
|
||
self.load_waits: list[int] = [] # wait_for_load_state("load", timeout=) — #2676
|
||
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:
|
||
self.waits.append(ms)
|
||
|
||
async def wait_for_load_state(self, state: str, timeout: int = 0) -> None:
|
||
self.load_waits.append(timeout)
|
||
|
||
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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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}'
|
||
|
||
|
||
# ── settle + in-page retry (#1917) ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_fetch_json_settle_uses_configured_ms(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Settle перед in-page fetch = FETCH_JSON_SETTLE_MS (а не хардкод 500)."""
|
||
page = _FakePage({"status": 200, "body": "ok"})
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_SETTLE_MS", 1777)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
|
||
asyncio.run(
|
||
server.fetch_json_handler(
|
||
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||
)
|
||
)
|
||
assert page.waits == [1777]
|
||
|
||
|
||
def test_fetch_json_passes_inpage_retry_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""В page.evaluate прокидываются retries/retryDelayMs из конфигурации (#1917)."""
|
||
page = _FakePage({"status": 200, "body": "ok", "attempts": 1})
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_INPAGE_RETRIES", 2)
|
||
monkeypatch.setattr(server, "FETCH_JSON_RETRY_DELAY_MS", 654)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
|
||
asyncio.run(
|
||
server.fetch_json_handler(
|
||
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||
)
|
||
)
|
||
payload = page.evaluate.await_args.args[1]
|
||
assert payload["retries"] == 2
|
||
assert payload["retryDelayMs"] == 654
|
||
|
||
|
||
# ── валидация / 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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
|
||
|
||
|
||
# ── crash-retry (#1915 deep-review nice-to-have) ──────────────────────────────────
|
||
|
||
|
||
class TargetClosedError(Exception):
|
||
"""Имя класса распознаётся _is_browser_crash (по type(exc).__name__) как краш."""
|
||
|
||
|
||
def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""_do_fetch_json: первый _fetch_json_once крашит браузер → relaunch + второй успех.
|
||
|
||
Проверяем именно crash-retry ветку: первый page.evaluate бросает
|
||
TargetClosedError (его type().__name__ ловит _is_browser_crash), что триггерит
|
||
_relaunch_browser + повторный _fetch_json_once на свежей странице. Результат
|
||
должен прийти из ВТОРОЙ попытки.
|
||
"""
|
||
crashing_page = _FakePage({"status": 0, "body": ""})
|
||
crashing_page.evaluate = AsyncMock(side_effect=TargetClosedError("Target page crashed"))
|
||
healthy_page = _FakePage({"status": 200, "body": '{"recovered": true}'})
|
||
|
||
# Первый инстанс — крашащийся; после relaunch подставляем здоровый.
|
||
server._browsers["avito"] = _FakeBrowser(crashing_page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
|
||
relaunched: list[str] = []
|
||
|
||
async def _fake_relaunch(provider: str) -> None:
|
||
relaunched.append(provider)
|
||
server._browsers[provider] = _FakeBrowser(healthy_page)
|
||
|
||
monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch)
|
||
|
||
result = asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
|
||
# Результат — из ВТОРОЙ (после relaunch) попытки, не из крашнувшей.
|
||
assert result == {"status": 200, "body": '{"recovered": true}'}
|
||
assert relaunched == ["avito"]
|
||
# Обе страницы успели открыться/закрыться (crashing — в finally, healthy — норм).
|
||
crashing_page.evaluate.assert_awaited_once()
|
||
healthy_page.evaluate.assert_awaited_once()
|
||
assert crashing_page.closed == 1
|
||
assert healthy_page.closed == 1
|
||
|
||
|
||
# ── гонка «execution context was destroyed» (#2676) ───────────────────────────────
|
||
|
||
|
||
class _FakeSequenceBrowser:
|
||
"""Отдаёт страницы по очереди: первая попытка ≠ вторая (retry на СВЕЖЕЙ странице)."""
|
||
|
||
def __init__(self, pages: list[_FakePage]) -> None:
|
||
self._pages = list(pages)
|
||
self.opened = 0
|
||
|
||
async def new_page(self) -> _FakePage:
|
||
page = self._pages[min(self.opened, len(self._pages) - 1)]
|
||
self.opened += 1
|
||
return page
|
||
|
||
|
||
def _no_relaunch(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||
"""Подменяет _relaunch_browser счётчиком — тест падает, если его всё-таки позвали."""
|
||
calls: list[str] = []
|
||
|
||
async def _fake(provider: str) -> None:
|
||
calls.append(provider)
|
||
|
||
monkeypatch.setattr(server, "_relaunch_browser", _fake)
|
||
return calls
|
||
|
||
|
||
# Обе формы, которые бросает playwright 1.60 (driver coreBundle.js) — короткая и полная.
|
||
@pytest.mark.parametrize(
|
||
"message",
|
||
[
|
||
"Page.evaluate: Execution context was destroyed, most likely because of a navigation.",
|
||
"Execution context was destroyed",
|
||
],
|
||
)
|
||
def test_do_fetch_json_retries_on_destroyed_context(
|
||
monkeypatch: pytest.MonkeyPatch, message: str
|
||
) -> None:
|
||
"""#2676: страница ушла в навигацию → повтор на свежей странице, БЕЗ relaunch.
|
||
|
||
До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash
|
||
матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500.
|
||
"""
|
||
racing_page = _FakePage({"status": 0, "body": ""})
|
||
racing_page.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
settled_page = _FakePage({"status": 200, "body": '{"recovered": true}'})
|
||
|
||
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page])
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242)
|
||
relaunched = _no_relaunch(monkeypatch)
|
||
|
||
result = asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
|
||
assert result == {"status": 200, "body": '{"recovered": true}'}
|
||
# Браузер живой — перезапускать его нельзя (тёплые cookies + ~10-20с).
|
||
assert relaunched == []
|
||
# Первая попытка НЕ ждала load (happy-path не удлиняется), повтор — ждал.
|
||
assert racing_page.load_waits == []
|
||
assert settled_page.load_waits == [4242]
|
||
assert racing_page.closed == 1 and settled_page.closed == 1
|
||
|
||
|
||
def test_do_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку."""
|
||
racing_page = _FakePage({"status": 0, "body": ""})
|
||
racing_page.evaluate = AsyncMock(side_effect=RuntimeError("Execution context was destroyed"))
|
||
settled_page = _FakePage({"status": 200, "body": "ok"})
|
||
settled_page.wait_for_load_state = AsyncMock( # type: ignore[method-assign]
|
||
side_effect=TimeoutError("Timeout 15000ms exceeded")
|
||
)
|
||
|
||
server._browsers["avito"] = _FakeSequenceBrowser([racing_page, settled_page])
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
_no_relaunch(monkeypatch)
|
||
|
||
result = asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
assert result == {"status": 200, "body": "ok"}
|
||
|
||
|
||
def test_do_fetch_json_gives_up_after_one_context_retry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл."""
|
||
message = "Page.evaluate: Execution context was destroyed"
|
||
first = _FakePage({"status": 0, "body": ""})
|
||
first.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
second = _FakePage({"status": 0, "body": ""})
|
||
second.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
|
||
server._browsers["avito"] = _FakeSequenceBrowser([first, second])
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
_no_relaunch(monkeypatch)
|
||
|
||
with pytest.raises(RuntimeError, match="Execution context was destroyed"):
|
||
asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
first.evaluate.assert_awaited_once()
|
||
second.evaluate.assert_awaited_once()
|
||
|
||
|
||
def test_do_fetch_json_does_not_retry_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Чужая ошибка НЕ ретраится — повтор невосстановимого жжёт бюджет прогона."""
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(side_effect=RuntimeError("net::ERR_PROXY_CONNECTION_FAILED"))
|
||
server._browsers["avito"] = _FakeSequenceBrowser([page])
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
_no_relaunch(monkeypatch)
|
||
|
||
with pytest.raises(RuntimeError, match="ERR_PROXY_CONNECTION_FAILED"):
|
||
asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
page.evaluate.assert_awaited_once()
|
||
|
||
|
||
def test_fetch_json_handler_500_carries_reason_in_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Причина отказа остаётся в теле 500 — её читает _raise_for_sidecar_status (#2708)."""
|
||
message = "Execution context was destroyed"
|
||
first = _FakePage({"status": 0, "body": ""})
|
||
first.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
second = _FakePage({"status": 0, "body": ""})
|
||
second.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
server._browsers["avito"] = _FakeSequenceBrowser([first, second])
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
_no_relaunch(monkeypatch)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> 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 "Execution context was destroyed" in _json_body(response)["error"]
|