gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_proxy.py
bot-backend 964867a943
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
feat(tradein/proxy): здоровье прокси по паре «узел × источник» (#2600 п.2)
Бан площадкой был глобальным: п.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
2026-08-05 17:14:12 +05:00

135 lines
7.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.

"""Общий 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)