All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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 2m43s
Бан площадкой был глобальным: п.1 на распознанный бан выключал узел целиком (enabled=false, disabled_reason='banned:<source>'). Реальность другая — Авито банит IP, а Яндекс через тот же IP ходит чисто, поэтому один забаненный источник выкидывал живой узел из пула для всех и худил пул быстрее, чем его пополняют (#2638). Плюс такое состояние не самолечилось: ipify площадку не эмулирует, бан не видит, а non-NULL disabled_reason блокирует авто-воскрешение (#2610) — нужен был ручной PATCH. Теперь бан — свойство ПАРЫ (proxy_id, source) в scrape_proxy_source_bans: acquire(source) не выдаёт узел только этому источнику, для остальных узел первосортный; снимается сам по времени. Срок эскалирует 6ч → 12 → 24 → 48 → 72 (потолок) на повторных банах той же пары; ban_count сбрасывается purge'ем истёкших строк через 7 суток — поэтому purge намеренно отложенный, а не по banned_until < now(). Защита последнего узла сохранена, но считается по источнику: если после бана у acquire(source) не останется кандидатов — бан не пишется, WARNING зовёт пополнять пул. Миграция 210 конвертирует прод-остатки п.1 (enabled=false + disabled_reason LIKE 'banned:%') в 6-часовые per-source баны и возвращает узлы в строй — иначе они висели бы выключенными вечно. Оператору активные баны видны в GET/PATCH /admin/proxies (source_bans) — без этого «узел включён, но не выдаётся» необъяснимо. Refs #2600
135 lines
7.4 KiB
Python
135 lines
7.4 KiB
Python
"""Общий helper выбора прокси для curl_cffi-путей (#2163), за флагом USE_PROXY_POOL_CURL.
|
||
|
||
Инвариант ship-dark + fallback:
|
||
- config.use_proxy_pool_curl=False (дефолт) ИЛИ proxy_provider=None → yield env-прокси
|
||
(env_fallback_url) — curl-пути ходят ровно как сейчас, прод не меняется (легитимный
|
||
dev/no-op путь — см. providers/cian/detail.py:118).
|
||
- Флаг on + пул выдал lease → yield lease.url; на выходе mark_health(ok) + release(lease).
|
||
ok=True если блок отработал без исключения, ok=False если внутри поднялось (бан/ошибка).
|
||
- Флаг on + пул пуст/ошибка acquire + окружение НЕ "production" (dev/test) → fallback на
|
||
env_fallback_url, НЕ падаем (легитимно для локальной разработки/тестов).
|
||
- Флаг on + пул пуст/ошибка acquire + окружение "production" (#2616 шаг 1) → НЕ идём на
|
||
env-прокси (все env-прокси мертвы, проверено вживую #2613) — поднимаем
|
||
`NoProxyAvailableError` ДО HTTP-запроса. Явный отказ вместо похода через мёртвый узел.
|
||
|
||
release ВСЕГДА в finally — lease не должен течь, даже если fetch кинул. mark_health/release
|
||
обёрнуты в best-effort try (проблема пула не должна ронять сбор).
|
||
|
||
Бан площадки (#2600 п.1): если исключение, поднятое ИЗНУТРИ `with curl_proxy_url(...) as
|
||
url:`, — `isinstance` от `ProxyBanError` (mixin, который уже наследуют `AvitoBlockedError`/
|
||
`DomClickBlockedError` и т.п. — см. `proxy_errors.ProxyBanError`), это НЕ просто
|
||
`mark_health(ok=False)` (транзиентный сбой, инкремент consecutive_fails), а немедленный
|
||
`mark_banned` — узел сразу снимается с выдачи ЭТОМУ провайдеру (per-source бан, #2600 п.2;
|
||
для остальных источников остаётся в строю), кроме случая когда это последний узел,
|
||
достижимый для провайдера (защита в `app.services.proxy_pool.mark_banned`).
|
||
Zero изменений для caller'а: любой provider, который уже поднимает свой Blocked-exception
|
||
ИЗНУТРИ блока, получает сигнал бесплатно — этот модуль намеренно НЕ импортирует
|
||
avito_exceptions/domclick_exceptions (generic-прокси-слой не должен знать про конкретные
|
||
провайдеры), только общий mixin.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from collections.abc import Iterator
|
||
from contextlib import contextmanager
|
||
from typing import TYPE_CHECKING
|
||
|
||
from scraper_kit.proxy_errors import NoProxyAvailableError, ProxyBanError
|
||
|
||
if TYPE_CHECKING:
|
||
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _is_prod(config: ScraperConfig | None) -> bool:
|
||
"""True если config задан и `environment == "production"` (#2616 шаг 1).
|
||
|
||
`getattr` с дефолтом "dev" — старые/минимальные ScraperConfig-заглушки без поля
|
||
`environment` (тесты, ещё не обновлённые фейки) трактуются как dev → поведение
|
||
не меняется для них (безопасный дефолт, не прод-отказ по умолчанию).
|
||
"""
|
||
return config is not None and getattr(config, "environment", "dev") == "production"
|
||
|
||
|
||
@contextmanager
|
||
def curl_proxy_url(
|
||
config: ScraperConfig | None,
|
||
proxy_provider: ProxyProvider | None,
|
||
provider: str,
|
||
*,
|
||
env_fallback_url: str | None,
|
||
) -> Iterator[str | None]:
|
||
"""Отдать effective proxy-url для curl_cffi (`proxies={http/https: url}`).
|
||
|
||
Args:
|
||
config: ScraperConfig (читается флаг use_proxy_pool_curl). None → env-fallback.
|
||
proxy_provider: пул прокси или None (off). None → env-fallback.
|
||
provider: имя провайдера для affinity ("avito"/"cian"/"yandex"/...).
|
||
env_fallback_url: прокси-url как сейчас (config.cian_proxy_url / scraper_proxy_url).
|
||
|
||
Yields effective url (может быть None = прямое подключение, как и раньше).
|
||
|
||
Raises:
|
||
NoProxyAvailableError: пул был реально задействован (use_pool=True), acquire()
|
||
вернул None/упал, И окружение "production" (#2616 шаг 1) — env-прокси мертвы,
|
||
падать на них молча нельзя. HTTP-запрос НЕ выполняется (raise до yield).
|
||
"""
|
||
use_pool = (
|
||
config is not None
|
||
and getattr(config, "use_proxy_pool_curl", False)
|
||
and proxy_provider is not None
|
||
)
|
||
|
||
lease = None
|
||
if use_pool:
|
||
assert proxy_provider is not None # для type-narrowing (use_pool это гарантирует)
|
||
try:
|
||
lease = proxy_provider.acquire(provider)
|
||
except Exception:
|
||
logger.warning(
|
||
"proxy_pool: acquire(%s) failed — fallback to env proxy", provider, exc_info=True
|
||
)
|
||
lease = None
|
||
|
||
if lease is None:
|
||
if use_pool and _is_prod(config):
|
||
# Пул реально задействован (прод) и пуст/сломан — env-прокси мертвы (#2613),
|
||
# НЕ идём на них молча. Явный отказ ДО HTTP-запроса (#2616 шаг 1).
|
||
logger.warning(
|
||
"proxy_pool: acquire(%s) empty in production — refusing (no HTTP request), "
|
||
"NOT falling back to dead env proxy (#2616)",
|
||
provider,
|
||
)
|
||
raise NoProxyAvailableError(provider)
|
||
# off / dev-test / пул пуст в dev → env как сейчас (легитимный dev/no-op путь).
|
||
yield env_fallback_url
|
||
return
|
||
|
||
assert proxy_provider is not None
|
||
ok = True
|
||
banned = False
|
||
try:
|
||
yield lease.url
|
||
except Exception as exc:
|
||
ok = False
|
||
banned = isinstance(exc, ProxyBanError)
|
||
raise
|
||
finally:
|
||
# mark_health/mark_banned/release — best-effort: проблема пула не должна
|
||
# ронять сбор. mark_banned ПЕРЕД mark_health(ok=False) — оба независимы
|
||
# (разные поля), но бан — более специфичный/сильный сигнал.
|
||
if banned:
|
||
try:
|
||
proxy_provider.mark_banned(lease, source=provider)
|
||
except Exception:
|
||
logger.warning("proxy_pool: mark_banned failed for %s", provider, exc_info=True)
|
||
try:
|
||
proxy_provider.mark_health(lease, ok)
|
||
except Exception:
|
||
logger.warning("proxy_pool: mark_health failed for %s", provider, exc_info=True)
|
||
try:
|
||
proxy_provider.release(lease) # ОБЯЗАТЕЛЬНО — lease не течёт
|
||
except Exception:
|
||
logger.warning("proxy_pool: release failed for %s", provider, exc_info=True)
|