gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/proxy_errors.py
bot-backend 8129e21b95
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-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 / backend-tests (pull_request) Successful in 4m51s
fix(tradein/cian): суточный бэкфилл истории — в прокси-пул, а не в env-прокси (#3197)
BrowserFetcher(source="cian") в cian_history_backfill конструировался без
proxy_provider/use_pool/environment — трёх аргументов, которые кладут "proxy" в тело
POST /fetch. Сайдкар брал свой env-прокси (SCRAPER_PROXY_URL): пул из 4 узлов, его
баны и ротация проходили мимо, а прод-отказ «пул пуст → не ходить на env/direct»
(#2616) на этом пути был мёртв, потому что смотрит на environment. Проводка теперь
как у соседей — domclick_detail_backfill и house_imv_backfill.

Ожившему отказу нужен обработчик: NoProxyAvailableError ловился общим except на
объявление, и батч крутил впустую весь список (пул пуст с первого — значит пуст и на
1000-м). Распознаём по цепочке причин, обрываем прогон, counters.no_proxy_stop=1 и
mark_failed вместо mark_banned — отказ нашей стороны не должен записываться как бан
Циана. Дома и оценки после стопа пропускаем: они идут через тот же пул.

caused_by_no_proxy вынесен в scraper_kit.proxy_errors (у avito #3288 и domclick #3283
живут приватные копии — их схлопывание отдельной правкой).
2026-09-06 02:27:03 +05:00

90 lines
6.4 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.

"""Общее исключение "нет доступного прокси" (#2616 шаг 1).
Раньше `proxy_pool.acquire()` вернувший `None` в прод-окружении означал молчаливый
fallback на env-прокси (`AVITO_PROXY_URL`/`CIAN_PROXY_URL`/`YANDEX_PROXY_URL`/
`BROWSER_PROXY_*`) — все они мертвы (подписку mobileproxy закрыли, проверено вживую
#2613: 407/connection refused/invalid credentials). Итог — код либо шёл в HTTP-запрос
через заведомо мёртвый узел, либо (browser-путь) camoufox лез напрямую с IP сервера.
`NoProxyAvailableError` — явный, распознаваемый маркер "у нас нет доступного прокси
прямо сейчас" (наша инфраструктура), НЕ "площадка нас забанила" (`AvitoBlockedError`/
`AvitoRateLimitedError` — площадка) и НЕ "сетевая ошибка" (`httpx`/`curl_cffi` transport-
исключения). Поднимается ДО HTTP-запроса — ни один сетевой вызов не делается.
Подниматься должен ТОЛЬКО когда пул РЕАЛЬНО задействован (`use_pool=True`, то есть
`ScraperConfig.use_proxy_pool_curl`/`use_proxy_pool_browser` включён И `ProxyProvider`
передан) И окружение прод (`ScraperConfig.environment == "production"`). В dev/test/
config=None — легитимный direct/env-fallback путь не трогаем (см. providers/cian/
detail.py:118 "Пусто → прямое подключение (dev/no-op)").
"""
from __future__ import annotations
class NoProxyAvailableError(RuntimeError):
"""Пул прокси пуст (или acquire упал) в прод-окружении — отказ, не HTTP-запрос.
Отличается от `AvitoBlockedError`/`AvitoRateLimitedError` (блокировка площадкой)
и от голых `httpx`/`curl_cffi` transport-исключений (сетевая ошибка) — это НАША
инфраструктура (нет живого прокси), не внешний блок.
"""
def __init__(self, provider: str) -> None:
self.provider = provider
super().__init__(
f"no proxy available for provider={provider!r} (pool empty in prod, "
f"refusing env/direct fallback — #2616)"
)
class ProxyBanError(Exception):
"""Маркер-mixin: распознанная страница-бан площадки (#2600 п.1).
НЕ заменяет provider-specific исключения (`AvitoBlockedError`, `DomClickBlockedError`
и т.п.) — они дополнительно наследуют этот класс (`class AvitoBlockedError(AvitoError,
ProxyBanError)`), так что весь существующий код (`except AvitoBlockedError:`,
`isinstance(exc, AvitoBlockedError)`) продолжает работать без изменений.
Назначение: дать ОБЩУЮ (провайдер-независимую) точку опоры для мест, которые НЕ знают
конкретный provider-exception класс — `providers/_proxy.py::curl_proxy_url` (контекст-
менеджер живёт в generic-прокси-слое, не должен импортировать avito_exceptions/
domclick_exceptions/…) детектирует бан через `isinstance(exc, ProxyBanError)` внутри
своего `except Exception` и сообщает пулу (`proxy_provider.mark_banned`) БЕЗ изменений
в вызывающем curl-коде — любой provider, который уже поднимает свой Blocked-exception
ИЗНУТРИ `with curl_proxy_url(...) as url:`, получает сигнал бесплатно.
НЕ включает `AvitoRateLimitedError` — 429/sidecar-исчерпание бюджета может быть нашей
сетевой/инфраструктурной проблемой (timeout, прокси недоступен), а не подтверждённой
страницей-баном; смешивать их значило бы стирать разницу «бан площадки» vs «сетевой
сбой», которую issue #2600 п.4 явно требует сохранить.
Транспортный (browser) путь `BrowserFetcher.report_ban()` — ОТДЕЛЬНЫЙ явный публичный
метод, не завязан на этот маркер: там нет общего try/except вокруг fetch+parse (fetch()
возвращает HTML успешно, бан распознаётся ПОЗЖЕ отдельным вызовом парсера), поэтому
авто-детект по типу исключения там не применим — вызывающий код сообщает явно.
"""
def caused_by_no_proxy(exc: BaseException) -> bool:
"""Прячется ли за этим исключением пустой пул прокси (#3197).
Опора — ТИП в цепочке `__cause__`/`__context__`, а не подстрока «no proxy available»
в тексте: провайдеры заворачивают любой сбой фетча в свои Blocked/Unavailable-
исключения, и «пул пуст» приезжает наверх под видом блокировки площадки, хотя
запрос не уходил вовсе. По тексту такое уже один раз объявили баном чужую строку
(#3272), поэтому здесь только isinstance.
Те же две копии живут приватно в `app/tasks/avito_detail_backfill.py` (#3288) и
`domclick_detail_backfill.py` (#3283); их схлопывание сюда — отдельная правка.
"""
seen: set[int] = set()
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
if isinstance(cur, NoProxyAvailableError):
return True
seen.add(id(cur))
cur = cur.__cause__ or cur.__context__
return False
__all__ = ["NoProxyAvailableError", "ProxyBanError", "caused_by_no_proxy"]