All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
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 1m31s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
Единственный код 500 означал и «площадка забанила», и «сайдкар упал»: разбор каждого инцидента начинался с ложного следа — в лог провайдера и в houses.imv_error_reason уезжала httpx-преамбула «Server error '500 Internal Server Error' for url 'http://tradein-browser:3000/fetch'», то есть текст ошибки называл гонца, а не виновника. - browser/server.py: BanPageDetectedError → 403 (доступ ограничен площадкой); 451 — про юридическую блокировку, это не она. Своих 403 сайдкар не отдаёт (400/422/503), код однозначен. classify_browser_probe не задета: у неё любой status >= 400 → "sidecar". Тело не меняется — ban_page/status на месте. - scraper_kit/browser_fetcher.py: текст SidecarBanPageError теперь свой — «площадка отдала бан-страницу (upstream 403, ответ сайдкара 403): …». Распознавание остаётся по ТЕЛУ и code-agnostic: tradein-browser — отдельный образ со своим деплоем, версии штатно расходятся на часы, и гейт по коду в этот час уводил бы отказ площадки в инфра-ветку. - Тесты: 403 → SidecarBanPageError; старый 500 + ban_page → он же; чистый 500 без ban_page → прежний инфра-диагноз; текст ошибки без «500»/«Server error». Refs #3288 п.4
395 lines
18 KiB
Python
395 lines
18 KiB
Python
"""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_clears_stale_challenge_status_once_settled(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""#3283: снятый челлендж не должен отдать наверх статус challenge-страницы.
|
||
|
||
Замер прода (прогон 5606): сайдкар пишет `_last_response_status` ОДИН раз, из
|
||
ответа на challenge-страницу (тут — 439), и никогда его не обновляет после
|
||
self-reload'а, которым PoW чинит себя сам. Клиент (avito/detail.py) трактует
|
||
403/439 как блок площадки НЕ глядя в HTML и выбрасывает уже вытащенную карточку.
|
||
После этой правки статус обязан стать None («неизвестен») — клиент такой статус
|
||
блоком не считает.
|
||
"""
|
||
page = _Page([_CHALLENGE_HTML, _REAL_HTML], [_Response(439)])
|
||
_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_challenge_timeout_keeps_status_as_is(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Челлендж НЕ снялся → ChallengeTimeoutError, статус ответа НЕ обнуляем.
|
||
|
||
Отказ остаётся отказом (требование задачи #3283): return settled — единственное
|
||
место, где мы решаем, что status протух; на ветке таймаута до него не доходит.
|
||
"""
|
||
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 1000)
|
||
page = _Page([_CHALLENGE_HTML], [_Response(439)]) # челлендж висит вечно
|
||
_install(monkeypatch, page)
|
||
|
||
with pytest.raises(server.ChallengeTimeoutError):
|
||
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||
|
||
assert server._last_response_status["avito"] == 439
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── #3239: 500-ка бан-страницы отличима от 500-ки транспорта ──────────────────
|
||
|
||
|
||
def _fetch_handler_error_body(
|
||
monkeypatch: pytest.MonkeyPatch, exc: Exception, upstream_status: int | None
|
||
) -> tuple[int, dict]:
|
||
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] = upstream_status
|
||
raise exc
|
||
|
||
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"})))
|
||
return response.status, _json_body(response)
|
||
|
||
|
||
def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Бан-страница несёт ban_page + апстрим-статус И ОТЛИЧИМЫЙ КОД 403.
|
||
|
||
Без признака в теле клиент видит обычную 500-ку, уводит отказ в транспортную
|
||
ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел —
|
||
регрессия, которую #3237 внёс, а #3239 чинит.
|
||
|
||
Код 403 (#3288 п.4): один и тот же 500 означал «площадка забанила» и
|
||
«сайдкар упал», и разбор инцидента начинался с ложного следа. Тело не
|
||
меняется — клиент опознаёт бан по нему (образы деплоятся врозь).
|
||
"""
|
||
status, body = _fetch_handler_error_body(
|
||
monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401
|
||
)
|
||
assert status == 403
|
||
assert body["ban_page"] is True
|
||
assert body["status"] == 401
|
||
assert "BanPageDetectedError" in body["error"]
|
||
|
||
|
||
def test_fetch_handler_transport_error_has_no_ban_page_flag(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Обычный сбой — признака нет вовсе, клиент трактует его как раньше."""
|
||
status, body = _fetch_handler_error_body(
|
||
monkeypatch, RuntimeError("NS_ERROR_PROXY_BAD_GATEWAY"), None
|
||
)
|
||
assert status == 500
|
||
assert "ban_page" not in body
|
||
|
||
|
||
def test_fetch_handler_ban_page_without_status_stays_null(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Апстрим-статуса нет — отдаём null, а не выдуманный код (#2764)."""
|
||
_status, body = _fetch_handler_error_body(
|
||
monkeypatch, server.BanPageDetectedError("бан-страница"), None
|
||
)
|
||
assert body["ban_page"] is True
|
||
assert body["status"] is None
|