gendesign/tradein-mvp/browser/test_server_http_status.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

295 lines
12 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_http_status.py — HTTP-статус целевой навигации в /fetch (#3196).
Проблема, установленная замером на проде 28.08.2026: ДомКлик отдаёт статическую
страницу «403 | Домклик» ровно на 26 624 байта, в которой НЕТ ни startpow, ни
qrator, ни капчи, ни PoW-скрипта. Ни один маркер из _CHALLENGE_MARKERS/_BAN_MARKERS
(оба сняты с авитовских страниц) в неё не попадает, поэтому отказ уезжал наверх как
валидный контент, парсер не находил состояние страницы, и прогон получал блок
неизвестной природы — 14 прогонов domclick_detail_backfill подряд с ban_kind=unknown.
_fetch_once игнорировал Response, который возвращает page.goto. Теперь статус
целевой навигации сохраняется в _last_response_status[provider] и отдаётся в теле
/fetch рядом с html — АДДИТИВНО, ключ "html" не меняется.
camoufox НЕ запускается: _browsers[provider] — поддельный browser/page (зеркалит
test_server_pow_challenge.py). wait_for_timeout на фейковой page — no-op.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_http_status.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import json
from pathlib import Path
from typing import Any
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)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Чистое per-provider состояние на каждый тест (зеркалит соседние тесты)."""
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, "_last_response_status", {})
monkeypatch.setattr(server, "_launched_proxy", {})
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
# Заглушка ДомКлика: 403 и НИ ОДНОГО маркера из _CHALLENGE_MARKERS/_BAN_MARKERS —
# ровно то, на чём ломался старый детектор.
_DOMCLICK_403_HTML = "<html><head><title>403 | Домклик</title></head><body></body></html>"
_REAL_HTML = "<html><body>listing card content</body></html>"
# Челлендж Авито: маркер startPow при HTTP 200 — путь, который трогать нельзя.
_CHALLENGE_HTML = "<html><body><script>startPow(1);</script></body></html>"
class _Response:
"""Поддельный playwright Response — интересует только .status."""
def __init__(self, status: int) -> None:
self.status = status
class _Page:
"""Поддельная page: goto отдаёт заданный Response, content() — заданный HTML.
goto_results — по одному элементу на вызов goto (None = «playwright вернул None»,
навигационная гонка/редирект). После исчерпания повторяет последний.
"""
def __init__(
self,
html_sequence: list[str],
goto_results: list[_Response | None],
) -> None:
self._html_sequence = html_sequence
self._goto_results = goto_results
self._content_calls = 0
self.goto_urls: list[str] = []
self.wait_for_timeout_calls: list[int] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> _Response | None:
idx = min(len(self.goto_urls), len(self._goto_results) - 1)
self.goto_urls.append(url)
return self._goto_results[idx]
async def wait_for_timeout(self, ms: int) -> None:
self.wait_for_timeout_calls.append(ms)
async def content(self) -> str:
idx = min(self._content_calls, len(self._html_sequence) - 1)
self._content_calls += 1
return self._html_sequence[idx]
async def close(self) -> None:
self.closed += 1
class _Browser:
def __init__(self, page: _Page) -> None:
self._page = page
async def new_page(self) -> _Page:
return self._page
def _install(monkeypatch: pytest.MonkeyPatch, page: _Page, provider: str = "avito") -> None:
server._browsers[provider] = _Browser(page)
monkeypatch.setattr(
server, "_RECYCLE_PAGES_BY_PROVIDER",
dict.fromkeys(server.PROVIDERS, 10_000),
)
monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0)
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {})
monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0)
def _json_body(response: Any) -> dict[str, Any]:
return json.loads(response.body.decode())
async def _coro(value: Any) -> Any:
return value
def _make_request(body: dict[str, Any]) -> Any:
request = make_mocked_request("POST", "/fetch")
request.json = lambda: _coro(body) # type: ignore[method-assign]
return request
# ── _status_of: чтение статуса ────────────────────────────────────────────────
def test_status_of_reads_response_status() -> None:
assert server._status_of(_Response(403)) == 403
def test_status_of_none_response_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) — не падаем, статуса нет."""
assert server._status_of(None) is None
# ── _fetch_once: статус пишется в per-provider состояние ──────────────────────
def test_fetch_once_records_target_status(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_DOMCLICK_403_HTML], [_Response(403)])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://domclick.ru/card/1"))
assert html == _DOMCLICK_403_HTML # контент отдан как раньше — правка аддитивна
assert server._last_response_status["avito"] == 403
def test_fetch_once_status_none_when_goto_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_REAL_HTML], [None])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
assert server._last_response_status["avito"] is None
def test_fetch_once_status_taken_from_target_not_origin(monkeypatch: pytest.MonkeyPatch) -> None:
"""Прогрев origin отдал 200, целевая навигация — 403. Наверх идёт 403."""
page = _Page([_DOMCLICK_403_HTML], [_Response(200), _Response(403)])
_install(monkeypatch, page)
asyncio.run(
server._fetch_once("avito", "https://domclick.ru/card/1", origin="https://domclick.ru/")
)
assert page.goto_urls == ["https://domclick.ru/", "https://domclick.ru/card/1"]
assert server._last_response_status["avito"] == 403
def test_fetch_once_resets_stale_status_before_navigation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Статус прошлого запроса не должен пережить следующую навигацию."""
server._last_response_status["avito"] = 403
page = _Page([_REAL_HTML], [None])
_install(monkeypatch, page)
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert server._last_response_status["avito"] is None
# ── статус в диагностике: 403/429 не ждут PoW ─────────────────────────────────
def test_fetch_once_skips_pow_wait_on_403(monkeypatch: pytest.MonkeyPatch) -> None:
"""403 + маркеры челленджа — отказ, а не проверка: бюджет ожидания не тратим.
Заглушку наверх НЕ отдаём: вернув её как валидный HTML, воскресили бы #3045 —
парсер Авито получил бы страницу челленджа вместо карточки.
"""
page = _Page([_CHALLENGE_HTML], [_Response(403)])
_install(monkeypatch, page)
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert page.wait_for_timeout_calls == [] # ни одного тика ожидания челленджа
def test_fetch_once_skips_pow_wait_on_429(monkeypatch: pytest.MonkeyPatch) -> None:
page = _Page([_CHALLENGE_HTML], [_Response(429)])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert page.wait_for_timeout_calls == []
def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
"""HTTP 200 с PoW-заглушкой (путь Авито) не задет: ждём и получаем контент."""
page = _Page([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML], [_Response(200)])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
assert page.wait_for_timeout_calls # ожидание челленджа реально шло
def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан."""
page = _Page(["<html>Доступ ограничен: проблема с IP</html>"], [_Response(200)])
_install(monkeypatch, page)
with pytest.raises(server.BanPageDetectedError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
# ── /fetch: статус доезжает в тело ответа ─────────────────────────────────────
def test_fetch_handler_returns_status_next_to_html(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = 403
return _DOMCLICK_403_HTML
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://domclick.ru/x"})))
assert response.status == 200
body = _json_body(response)
assert body["html"] == _DOMCLICK_403_HTML # старый ключ не тронут
assert body["status"] == 403
def test_fetch_handler_status_null_without_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""Статуса нет (goto вернул None) — ответ не ломается, status=null."""
monkeypatch.setattr(server, "IS_PROD", False)
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
return True
async def _fake_do_fetch(provider: str, url: str, **_kw: Any) -> str:
server._last_response_status[provider] = None
return _REAL_HTML
monkeypatch.setattr(server, "_ensure_browser", _ensure)
monkeypatch.setattr(server, "_do_fetch", _fake_do_fetch)
response = asyncio.run(server.fetch_handler(_make_request({"url": "https://www.avito.ru/x"})))
body = _json_body(response)
assert body["html"] == _REAL_HTML
assert body["status"] is None