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
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 живут приватные копии — их схлопывание отдельной правкой).
90 lines
6.4 KiB
Python
90 lines
6.4 KiB
Python
"""Общее исключение "нет доступного прокси" (#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"]
|