All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
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
Fix-forward к смерженному PR #2716. Прод-проверка того PR показала, что признак распознаётся (в логе появилась новая ветка), но сам повтор не помогает: 10:03:49 WARNING страница ушла в навигацию (Error), retry fetch-json с ожиданием load 10:03:54 ERROR Page.evaluate: Execution context was destroyed Воспроизведено вживую дважды (безобидный robots.txt, origin = корень и город) — обе попытки падают одинаково, при этом `/fetch` того же URL отдаёт 200 и настоящий robots.txt, то есть площадка не блокирует и прокси жив. Причина: навигация origin-страницы происходит ПОСЛЕ события load, поэтому ни settle, ни ожидание load её не опережают, а повтор на СВЕЖЕЙ странице честно воспроизводит ту же первую навигацию. Правка: 1. Повтор evaluate на ТОЙ ЖЕ странице: к моменту исключения навигация уже случилась, у страницы новый и уже стабильный контекст. Стоит миллисекунды против полной ре-навигации. 2. Внешний повтор на свежей странице УБРАН — прод показал, что он не может выиграть, а стоил полной ре-навигации. Страховкой остаётся один повтор всего запроса на стороне клиента (BrowserFetcher.fetch_json), который был и раньше. 3. JS in-page fetch вынесен в константу (нужен двум вызовам), ожидание load — в _wait_for_load_best_effort. Refs #2676
615 lines
26 KiB
Python
615 lines
26 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) ───────────────────────────────
|
||
|
||
|
||
def test_fetch_json_retries_evaluate_on_the_same_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""#2676 (живой прод 06.08): навигация УЖЕ случилась → повтор evaluate на той же странице.
|
||
|
||
Ключевое отличие от внешнего ретрая: страница НЕ пересоздаётся. Пересоздание
|
||
воспроизводит ту же первую навигацию origin'а (проверено на проде: обе попытки
|
||
падали одинаково), а после исключения у страницы уже новый, стабильный контекст.
|
||
"""
|
||
page = _FakePage({"status": 200, "body": "ok"})
|
||
page.evaluate = AsyncMock(
|
||
side_effect=[
|
||
RuntimeError("Page.evaluate: Execution context was destroyed, "
|
||
"most likely because of a navigation."),
|
||
{"status": 200, "body": '{"recovered": true}'},
|
||
]
|
||
)
|
||
browser = _FakeBrowser(page)
|
||
server._browsers["avito"] = browser
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_LOAD_WAIT_MS", 4242)
|
||
|
||
result = asyncio.run(
|
||
server._fetch_json_once(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
|
||
assert result == {"status": 200, "body": '{"recovered": true}'}
|
||
assert page.evaluate.await_count == 2
|
||
# Ни новой страницы, ни повторной навигации — только ожидание конца текущей.
|
||
assert browser.opened == 1
|
||
assert page.goto_urls == ["https://www.avito.ru/"]
|
||
assert page.load_waits == [4242]
|
||
assert page.closed == 1
|
||
|
||
|
||
def test_fetch_json_same_page_retry_happens_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Повтор evaluate ровно один: вторая та же ошибка уезжает наверх (там внешняя ветка)."""
|
||
message = "Execution context was destroyed"
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
|
||
with pytest.raises(RuntimeError, match="Execution context was destroyed"):
|
||
asyncio.run(
|
||
server._fetch_json_once(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
assert page.evaluate.await_count == 2
|
||
assert page.closed == 1
|
||
|
||
|
||
def test_fetch_json_no_same_page_retry_for_unrelated_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Чужая ошибка evaluate не даёт второго прохода — повтор стоит денег."""
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(side_effect=RuntimeError("boom"))
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
|
||
with pytest.raises(RuntimeError, match="boom"):
|
||
asyncio.run(
|
||
server._fetch_json_once(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
page.evaluate.assert_awaited_once()
|
||
|
||
|
||
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_recovers_from_destroyed_context(
|
||
monkeypatch: pytest.MonkeyPatch, message: str
|
||
) -> None:
|
||
"""#2676: обе формы сообщения playwright распознаются и переживаются повтором.
|
||
|
||
До правки такая ошибка не попадала ни в одну ветку восстановления (_is_browser_crash
|
||
матчит только закрытие цели/браузера/соединения) и уезжала наверх как 500.
|
||
"""
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(
|
||
side_effect=[RuntimeError(message), {"status": 200, "body": '{"recovered": true}'}]
|
||
)
|
||
browser = _FakeBrowser(page)
|
||
server._browsers["avito"] = browser
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
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 == []
|
||
# И новой страницы тоже нет: повтор идёт по уже перешедшему контексту.
|
||
assert browser.opened == 1
|
||
assert page.evaluate.await_count == 2
|
||
|
||
|
||
def test_fetch_json_retry_survives_load_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Ожидание load на повторе — best-effort: таймаут не отменяет саму попытку."""
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(
|
||
side_effect=[RuntimeError("Execution context was destroyed"), {"status": 200, "body": "ok"}]
|
||
)
|
||
page.wait_for_load_state = AsyncMock( # type: ignore[method-assign]
|
||
side_effect=TimeoutError("Timeout 15000ms exceeded")
|
||
)
|
||
server._browsers["avito"] = _FakeBrowser(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:
|
||
"""Повтор ровно один: вторая та же ошибка уезжает наверх, а не крутит цикл.
|
||
|
||
Свежей страницы тут намеренно НЕТ: пересоздание воспроизводит ту же первую
|
||
навигацию origin'а (прод 06.08 — обе попытки упали одинаково), а страховкой
|
||
остаётся один повтор всего запроса на стороне клиента (BrowserFetcher).
|
||
"""
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(
|
||
side_effect=RuntimeError("Page.evaluate: Execution context was destroyed")
|
||
)
|
||
browser = _FakeBrowser(page)
|
||
server._browsers["avito"] = browser
|
||
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/",
|
||
)
|
||
)
|
||
assert page.evaluate.await_count == 2
|
||
assert browser.opened == 1
|
||
|
||
|
||
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"] = _FakeBrowser(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"
|
||
page = _FakePage({"status": 0, "body": ""})
|
||
page.evaluate = AsyncMock(side_effect=RuntimeError(message))
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
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"]
|