From 1200af58f52838cd7727ed5b6854ef142f5f094c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 6 Sep 2026 01:58:02 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D1=81=D0=B0=D0=B9=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B1=D0=B0=D0=BD=20=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=89=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=20403,=20=D0=BD=D0=B5=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единственный код 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 --- .../tests/test_kit_browser_fetcher_status.py | 46 +++++++++++++++++-- tradein-mvp/browser/server.py | 17 ++++++- .../browser/test_server_http_status.py | 10 ++-- .../src/scraper_kit/browser_fetcher.py | 23 +++++++++- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py index 506a9dc8..b9ee2163 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_status.py @@ -175,9 +175,10 @@ def test_ban_kind_values_fit_scrape_runs_check() -> None: # 'platform', то есть ротация IP не запустилась бы вовсе. -def _sidecar_response(body: Any) -> httpx.Response: +def _sidecar_response(body: Any, status: int = 403) -> httpx.Response: + """Ответ сайдкара. 403 — код подтверждённого бана с #3288 п.4 (был 500).""" return httpx.Response( - 500, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch") + status, json=body, request=httpx.Request("POST", "http://tradein-browser:3000/fetch") ) @@ -190,6 +191,41 @@ def test_ban_page_body_raises_typed_error_with_upstream_status() -> None: 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}) @@ -198,7 +234,8 @@ def test_ban_page_error_is_httpx_status_error() -> None: def test_plain_500_stays_plain_status_error() -> None: - resp = _sidecar_response({"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}) + """Настоящий сбой сайдкара остаётся 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) @@ -230,7 +267,8 @@ def test_non_json_error_body_does_not_break() -> None: def test_success_response_raises_nothing() -> None: resp = httpx.Response( - 200, json={"html": "", "status": 200}, + 200, + json={"html": "", "status": 200}, request=httpx.Request("POST", "http://tradein-browser:3000/fetch"), ) _raise_for_sidecar_status(resp) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index cef25139..53408256 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1466,10 +1466,23 @@ async def fetch_handler(request: web.Request) -> web.Response: # транспорта, и только за первым стоит report_ban на клиенте. # До этой правки оба случая приезжали одинаковой 500-кой, клиент # различить их не мог и настоящий отказ площадки переставал - # ротировать узел (регрессия #3237). Код ответа НЕ меняем: на 500 - # завязана classify_browser_probe, признак несёт тело. + # ротировать узел (регрессия #3237). + # + # #3288 п.4: код ответа теперь РАЗНЫЙ. 500 означало и «площадка + # забанила», и «сайдкар упал», поэтому разбор каждого инцидента + # начинался с ложного следа («Server error 500 for url + # tradein-browser:3000/fetch» в логе провайдера). 403 — + # «в доступе отказано», ровно то, что произошло НА ПЛОЩАДКЕ; + # 451 семантически про юридическую блокировку, это не она. + # Своих 403 сайдкар не отдаёт (auth/валидация — 400/422/503), + # так что код однозначен. classify_browser_probe не задет: у неё + # любой status >= 400 → "sidecar", 403 и 500 там неразличимы. + # Признак ban_page в теле ОСТАЁТСЯ — клиент опознаёт бан по нему, + # а не по коду (сайдкар и backend — разные образы, версии едут + # врозь). error_body["ban_page"] = True error_body["status"] = _last_response_status.get(provider) + return web.json_response(error_body, status=403) return web.json_response(error_body, status=500) # Аддитивно (#3196): ключ "html" на месте и не изменился — клиент, читающий diff --git a/tradein-mvp/browser/test_server_http_status.py b/tradein-mvp/browser/test_server_http_status.py index c13d0e12..4a975c3b 100644 --- a/tradein-mvp/browser/test_server_http_status.py +++ b/tradein-mvp/browser/test_server_http_status.py @@ -354,16 +354,20 @@ def _fetch_handler_error_body( def test_fetch_handler_marks_ban_page_in_error_body(monkeypatch: pytest.MonkeyPatch) -> None: - """Бан-страница несёт ban_page + апстрим-статус. + """Бан-страница несёт ban_page + апстрим-статус И ОТЛИЧИМЫЙ КОД 403. - Без этого признака клиент видит обычную 500-ку, уводит отказ в транспортную + Без признака в теле клиент видит обычную 500-ку, уводит отказ в транспортную ветку (report_ban там не зовётся) и перестаёт ротировать отказавший узел — регрессия, которую #3237 внёс, а #3239 чинит. + + Код 403 (#3288 п.4): один и тот же 500 означал «площадка забанила» и + «сайдкар упал», и разбор инцидента начинался с ложного следа. Тело не + меняется — клиент опознаёт бан по нему (образы деплоятся врозь). """ status, body = _fetch_handler_error_body( monkeypatch, server.BanPageDetectedError("статический отказ площадки"), 401 ) - assert status == 500 # код НЕ меняем: на него завязана classify_browser_probe + assert status == 403 assert body["ban_page"] is True assert body["status"] == 401 assert "BanPageDetectedError" in body["error"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index 95c116bc..280a5d4f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -164,6 +164,16 @@ def _sidecar_ban_page_status(resp: httpx.Response) -> tuple[bool, int | None]: Тело — ``{"error": ..., "ban_page": true, "status": }``. Сайдкар старой сборки ключей не отдаёт → (False, None), поведение как до правки. ``bool`` отсекаем явно: он подтип ``int`` и JSON ``true`` уехал бы статусом. + + Признак — ТЕЛО, а не код ответа, и проверка намеренно остаётся + code-agnostic: с #3288 сайдкар отвечает на бан 403, до него отвечал 500, а + ``tradein-browser`` — ОТДЕЛЬНЫЙ образ (``gendesign-tradein-browser``, + docker-compose.prod.yml) со своим деплоем, т.е. версии сайдкара и backend + штатно разъезжаются на несколько часов. Оба кода дают одинаковый диагноз, + гейта по коду тут нет и заводить его не нужно: он ничего не добавит (свой + ``ban_page`` умеет писать только наш сайдкар), а в час рассинхрона снова + уводил бы отказ площадки в инфра-ветку. Снимать «совместимость» поэтому + нечего — 500-ветка исчезнет сама, когда сайдкар перестанет её отдавать. """ try: body = resp.json() @@ -203,8 +213,19 @@ def _raise_for_sidecar_status(resp: httpx.Response) -> None: # #3239: тип несёт диагноз наверх — подстрокой в тексте его искать # нельзя, detail обрезан до 300 символов и формулировка отказа # менялась дважды за месяц. + # + # #3288 п.4: текст СВОЙ, без httpx-преамбулы `{exc}`. Она печатала + # «Server error '500 Internal Server Error' for url + # 'http://tradein-browser:3000/fetch'», и этой строкой отказ ПЛОЩАДКИ + # уезжал в лог провайдера и в imv_error_reason как падение НАШЕГО + # сайдкара — разбор инцидента каждый раз начинался с ложного следа + # («текст ошибки называет гонца»). Код ответа сайдкара оставляем в + # тексте отдельным числом: он отличает 403 нового сайдкара от 500 + # старого, но больше ничего не значит. raise SidecarBanPageError( - f"{exc} | tradein-browser: {detail or 'ban page'}", + f"tradein-browser: площадка отдала бан-страницу " + f"(upstream {upstream_status if upstream_status is not None else '?'}, " + f"ответ сайдкара {resp.status_code}): {detail or 'ban page'}", request=exc.request, response=exc.response, upstream_status=upstream_status,