gendesign/tradein-mvp/browser/test_server_fetch_json.py
bot-backend b9025de666
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / 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
CI Trade-In / browser-tests (pull_request) Successful in 1m5s
fix(tradein/domclick): площадка отдаёт одну карточку на процесс браузера, а мы держали один на весь прогон (#3205)
Замер на проде 2026-08-29. Три независимых запуска camoufox через прод-прокси,
идём по карточкам до первого отказа — каждый раз одно и то же: карточка #1
даёт 200 и ~1 МБ с SSR-стейтом, карточка #2 даёт 401 и страницу отказа на
26 625 байт. A/B на восьми карточках: свой браузер на каждую — 4 из 4 успеха,
один браузер на четыре — 1 из 4. Прямой выход с сервера и выход через прокси
неразличимы (1 из 8 в обоих условиях), то есть дело не в IP.

Свежего КОНТЕКСТА не хватает: в контрольном замере каждая карточка бралась
через browser.new_page(), в новом изолированном контексте, — и всё равно отказ
со второй. Признак живёт на уровне процесса, camoufox генерирует отпечаток при
запуске, а не при создании контекста. Отсюда: reset_context (#3118) эту задачу
не решает в принципе. Цена перезапуска — 0.6 с (3.5 с только первый, холодный).

- PROVIDERS: добавлен "domclick" (+ host-detect). Раньше он проваливался в
  generic и делил браузер со счётчиком страниц с прочим трафиком — при пороге
  перезапуска 1 это было бы неверно.
- BROWSER_RECYCLE_PAGES стал поставщик-зависимым (_resolve_recycle_pages +
  BROWSER_RECYCLE_PAGES_{PROVIDER}), по образцу BROWSER_BLOCK_IMAGES_{PROVIDER}
  из #3185. Код-дефолт domclick=1, остальным прежние 15 — у Авито и Циана узор
  другой и своего замера под него нет.

Отдельно починены ~24 холостых охранника в тестах. Они делали
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000), чтобы запретить
перезапуск браузера; после перехода на словарь этот патч перестал на что-либо
влиять, и набор оставался зелёным лишь потому, что ни один тест не делает 15
страниц подряд. Теперь патчится _RECYCLE_PAGES_BY_PROVIDER, а сама глобальная
константа убрана, чтобы её не патчили снова. Проверено мутацией: при пороге 1
для всех провайдеров падают ровно три теста, которые этот дефолт и проверяют,
остальные 155 удерживаются — значит охранники работают.
2026-08-29 13:14:20 +03:00

663 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 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"]