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
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""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)
|