gendesign/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py
bot-backend 1200af58f5
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
fix(tradein): сайдкар отдаёт подтверждённый бан площадки кодом 403, не 500
Единственный код 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
2026-09-06 01:58:02 +05:00

274 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.

"""HTTP-статус сайдкара наверх: `BrowserFetcher.last_response_status` (#3196).
Сайдкар (tradein-mvp/browser/server.py) теперь кладёт в тело /fetch HTTP-код целевой
навигации рядом с html: ``{"html": ..., "status": <int|null>}``. Kit выносит его на
инстанс фетчера — АТРИБУТОМ, а не возвратом ``fetch()``: поток управления менять
нельзя, ``fetch()`` по-прежнему отдаёт ``str`` и по-прежнему не бросает там, где не
бросал раньше.
Зачем: ДомКлик отдаёт статическую страницу «403 | Домклик» на 26 624 байта, где нет
ни startpow, ни qrator, ни капчи — ни один текстовый маркер сайдкара (все сняты с
Авито) на неё не срабатывает, и отказ уезжал наверх как валидный контент. 14 прогонов
domclick_detail_backfill подряд получили ban_kind=unknown ровно поэтому.
Инварианты:
- status из тела → last_response_status (int) на КАЖДЫЙ успешный fetch;
- ключа "status" нет (сайдкар старой версии) ИЛИ он null → None, БЕЗ исключения;
- status нечислового типа → None (мусор в теле не должен ронять фетч);
- fetch упал → last_response_status сброшен в None (не отдаём статус прошлого);
- ban_kind_from_status раскладывает код в значение, допустимое CHECK-ограничением
scrape_runs.ban_kind ("platform" | "infra" | "unknown" | NULL).
httpx полностью замокан (зеркалит test_kit_browser_fetcher_proxy_pool.py).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from scraper_kit.browser_fetcher import (
BrowserFetcher,
SidecarBanPageError,
_raise_for_sidecar_status,
ban_kind_from_status,
)
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
"""httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json."""
resp = MagicMock()
if raise_exc is not None:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.json.return_value = json_payload
client = MagicMock()
client.post = AsyncMock(return_value=resp)
client.aclose = AsyncMock(return_value=None)
return client
async def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
"""Реально входит в `__aenter__`, потом подменяет httpx-клиент."""
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
await bf.__aenter__()
bf._client = client
return bf
# ── last_response_status ──────────────────────────────────────────────────────
async def test_status_starts_as_none() -> None:
"""До первого fetch статуса нет — атрибут существует и равен None."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
assert bf.last_response_status is None
async def test_status_from_body_is_exposed() -> None:
client = _mock_client({"html": "<403 page>", "status": 403})
bf = await _fetcher(client, source="domclick")
html = await bf.fetch("https://domclick.ru/card/1")
assert html == "<403 page>" # поток управления не изменился — fetch отдаёт str
assert bf.last_response_status == 403
async def test_status_updated_on_every_fetch() -> None:
"""Атрибут обновляется КАЖДЫМ _post_fetch, а не только первым."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="domclick")
await bf.fetch("https://domclick.ru/1")
assert bf.last_response_status == 200
client.post.return_value.json.return_value = {"html": "<403>", "status": 403}
await bf.fetch("https://domclick.ru/2")
assert bf.last_response_status == 403
async def test_missing_status_key_is_none_and_does_not_raise() -> None:
"""Сайдкар старой версии (тело без "status") — фетч проходит, статуса просто нет."""
client = _mock_client({"html": "<ok>"})
bf = await _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
assert bf.last_response_status is None
async def test_null_status_is_none() -> None:
"""goto вернул None (редирект/навигационная гонка) → сайдкар шлёт status=null."""
client = _mock_client({"html": "<ok>", "status": None})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_non_int_status_is_ignored() -> None:
"""Мусор в поле status не должен ронять фетч — читается как «статуса нет»."""
client = _mock_client({"html": "<ok>", "status": "403"})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/x")
assert bf.last_response_status is None
async def test_status_reset_on_failed_fetch() -> None:
"""Фетч упал — не отдаём статус ПРОШЛОГО запроса."""
client = _mock_client({"html": "<ok>", "status": 200})
bf = await _fetcher(client, source="avito")
await bf.fetch("https://avito.ru/1")
assert bf.last_response_status == 200
client.post.side_effect = RuntimeError("transport down")
with pytest.raises(RuntimeError):
await bf.fetch("https://avito.ru/2")
assert bf.last_response_status is None
# ── ban_kind_from_status ──────────────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "expected"),
[
(403, "platform"),
(429, "platform"),
(500, "infra"),
(502, "infra"),
(599, "infra"),
(200, None),
(301, None),
(404, None),
(None, None),
],
)
def test_ban_kind_from_status(status: int | None, expected: str | None) -> None:
assert ban_kind_from_status(status) == expected
def test_ban_kind_values_fit_scrape_runs_check() -> None:
"""Возврат обязан быть пригоден для scrape_runs.ban_kind как есть."""
allowed = {"platform", "infra", "unknown", None}
for status in (None, 200, 301, 403, 404, 429, 499, 500, 503, 599, 600):
assert ban_kind_from_status(status) in allowed
# ── #3239: подтверждённая бан-страница отличима от прочих 500-ок ──────────────
#
# Тип, а не подстрока: _raise_for_sidecar_status обрезает тело до 300 символов, и
# формулировка отказа у сайдкара менялась дважды за месяц. upstream_status нужен
# отдельным полем — на error-пути fetch() обнуляет last_response_status, и без
# него у DomClick (отказ приходит с 401) диагноз стал бы 'unknown' вместо
# 'platform', то есть ротация IP не запустилась бы вовсе.
def _sidecar_response(body: Any, status: int = 403) -> httpx.Response:
"""Ответ сайдкара. 403 — код подтверждённого бана с #3288 п.4 (был 500)."""
return httpx.Response(
status, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
def test_ban_page_body_raises_typed_error_with_upstream_status() -> None:
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401}
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
def test_ban_page_legacy_500_still_recognised() -> None:
"""Сайдкар старого образа отдаёт тот же ban_page с кодом 500 — диагноз тот же.
tradein-browser собирается ОТДЕЛЬНЫМ образом и деплоится своим шагом, так что
после выката #3288 клиент часами работает со старым сайдкаром. Гейта по коду
нет намеренно: он бы в этот час уводил отказ площадки в инфра-ветку.
"""
resp = _sidecar_response(
{"error": "BanPageDetectedError: статический отказ", "ban_page": True, "status": 401},
status=500,
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status == 401
def test_ban_page_message_names_platform_not_sidecar_500() -> None:
"""Текст ошибки называет виновника: отказ ПЛОЩАДКИ, а не падение нашего сайдкара.
До #3288 п.4 сюда уезжала httpx-преамбула «Server error '500 Internal Server
Error' for url 'http://tradein-browser:3000/fetch'» — и ровно она попадала в
лог провайдера и в imv_error_reason, отправляя разбор по ложному следу.
"""
resp = _sidecar_response(
{"error": "BanPageDetectedError: доступ ограничен", "ban_page": True, "status": 403}
)
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
message = str(excinfo.value)
assert "500" not in message
assert "Server error" not in message
assert "бан-страницу" in message
assert "upstream 403" in message
def test_ban_page_error_is_httpx_status_error() -> None:
"""Подкласс — иначе retry-политика fetch() и ловля у прочих поставщиков сломались бы."""
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": 403})
with pytest.raises(httpx.HTTPStatusError):
_raise_for_sidecar_status(resp)
def test_plain_500_stays_plain_status_error() -> None:
"""Настоящий сбой сайдкара остаётся 500-кой БЕЗ ban_page → инфра-диагноз, как раньше."""
resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}, status=500)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_ban_page_without_status_gives_none() -> None:
resp = _sidecar_response({"error": "BanPageDetectedError: x", "ban_page": True, "status": None})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_ban_page_true_as_bool_status_is_rejected() -> None:
"""JSON true не должен уехать статусом: bool — подтип int (та же ловушка, что в #3196)."""
resp = _sidecar_response({"error": "x", "ban_page": True, "status": True})
with pytest.raises(SidecarBanPageError) as excinfo:
_raise_for_sidecar_status(resp)
assert excinfo.value.upstream_status is None
def test_non_json_error_body_does_not_break() -> None:
resp = httpx.Response(
500, text="not json", request=httpx.Request("POST", "http://tradein-browser:3000/fetch")
)
with pytest.raises(httpx.HTTPStatusError) as excinfo:
_raise_for_sidecar_status(resp)
assert not isinstance(excinfo.value, SidecarBanPageError)
def test_success_response_raises_nothing() -> None:
resp = httpx.Response(
200,
json={"html": "<html/>", "status": 200},
request=httpx.Request("POST", "http://tradein-browser:3000/fetch"),
)
_raise_for_sidecar_status(resp)