All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m11s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 7m37s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 13s
608 lines
32 KiB
Python
608 lines
32 KiB
Python
"""Ротация exit-IP прокси по требованию, со счётчиком и громким отказом (#2600 п.5).
|
||
|
||
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный
|
||
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
|
||
|
||
Два провайдера, диспетчеризуются по хосту scrape_proxies.rotate_url (см.
|
||
"⛔ Хост-пиннинг" ниже) — секрет каждого провайдера уходит ТОЛЬКО на его хост.
|
||
|
||
── ASocks (api.asocks.com) ────────────────────────────────────────────────────
|
||
|
||
Контекст (эмпирика, issue #2600 п.5 — проверено владельцем аккаунта/пробой):
|
||
- Документированный публичный API ASocks (GET /v2/proxy/refresh/{portId}?apiKey=)
|
||
для безлимитных портов НЕ работает.
|
||
- Ротация сменой session-суффикса логина (-session-N) НЕ работает — exit-IP
|
||
не меняется (три варианта дали один и тот же IP).
|
||
- Единственный рабочий путь — ручка веб-кабинета:
|
||
POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip
|
||
Authorization: Bearer <токен>
|
||
Без заголовка провайдер отдаёт 401 {"success": false, "message": "Unauthenticated"}.
|
||
scrape_proxies.rotate_url уже несёт этот URL (миграция 199) — токен НЕ в URL,
|
||
он только в ASOCKS_API_TOKEN (env, app.core.config.settings.asocks_api_token).
|
||
- Лимит провайдера: 3 ротации в сутки на порт.
|
||
- Токен — сессионный, однажды протухнет (осознанное решение владельца аккаунта).
|
||
Когда это случится, провайдер ответит 401 — это ГРОМКИЙ отказ ниже
|
||
(logger.error + Sentry/GlitchTip capture_message), а не молчаливая остановка.
|
||
|
||
── mobileproxy.space (aproxy.site) ─────────────────────────────────────────────
|
||
|
||
Протокол ротации (эмпирика, проверено вживую на проде):
|
||
- GET <rotate_url>&format=json — БЕЗ заголовков авторизации, секрет (proxy_key)
|
||
уже внутри URL в query-string. Без format=json провайдер отдаёт HTML-страницу
|
||
вместо JSON.
|
||
- Успех: {"status": "OK", "code": 200, "new_ip": "...", "rt": "2.41", "proxy_id": N}.
|
||
Признак успеха — ИМЕННО status == "OK" в теле, не HTTP-код ответа.
|
||
"rt" — секунды на переподключение канала (2.4-11.7 в замерах), отдаём
|
||
вызывающему как RotationResult.reconnect_delay_s — следующая задача
|
||
(интеграция в скраперы) должна выждать это время перед повторным запросом.
|
||
- Кулдауна у провайдера не обнаружено (13 ротаций подряд — 13 уникальных IP,
|
||
две подряд прошли за 5 и 8 секунд). MOBILEPROXY_DAILY_ROTATION_LIMIT (200) —
|
||
это ТОЛЬКО наш предохранитель от runaway-цикла, не лимит провайдера.
|
||
- Домен changeip.mobileproxy.space из прод-сети не резолвится/не отвечает —
|
||
поддерживаем только aproxy.site.
|
||
- proxy_key — секрет ровно как ASOCKS_API_TOKEN: не должен появиться ни в
|
||
reason, ни в исключениях, ни в scrape_proxy_rotations, ни в логах (см.
|
||
"⛔ Токен никогда не должен появиться..." ниже — тот же принцип).
|
||
|
||
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
|
||
Против лимита (3/сутки ASocks, 200/сутки mobileproxy) считаются ТОЛЬКО попытки,
|
||
реально дошедшие до провайдера и обработанные им — т.е. любой HTTP-ответ
|
||
провайдера, КРОМЕ 401. Обоснование: 401 — это буквально описание провайдера
|
||
"Unauthenticated": запрос отсеян на уровне аутентификации ДО обращения к самой
|
||
логике ротации порта, провайдер не мог засчитать использование ротации тому,
|
||
кого даже не подтвердил (у mobileproxy авторизации нет вовсе, поэтому 401 для
|
||
него не ожидается, но правило проверки общее для обоих провайдеров). Сетевые
|
||
ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не
|
||
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
|
||
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
|
||
доходят вовсе — в таблицу не пишутся и лимит не трогают.
|
||
|
||
quota-consuming := http_status IS NOT NULL AND http_status != 401
|
||
(успех 200 И любой не-401 ответ провайдера, включая его собственные 4xx/5xx —
|
||
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
|
||
реальной rotate-логики и мог быть учтён в лимите на его стороне).
|
||
|
||
⛔ Токен/ключ провайдера никогда не должен появиться в возвращаемом клиенту
|
||
reason, в тексте исключения, ни в одной записи scrape_proxy_rotations. Прецедент
|
||
утечки через str(exc) — тот же паттерн, что закрывал (до удаления #2616 шаг 3)
|
||
changeip-путь admin.rotate_proxy_ip: httpx-исключения несут полный request
|
||
URL/детали, поэтому наружу — только нейтральный reason, полные детали — в лог с
|
||
exc_info=True.
|
||
|
||
⛔ Хост-пиннинг (security review PR #2611): scrape_proxies.rotate_url колонка
|
||
НЕОДНОРОДНА — часть строк пула несёт ASocks-ссылки, часть — mobileproxy
|
||
aproxy.site-ссылки (`https://aproxy.site/?proxy_key=<секрет>`), формат которых
|
||
похож на старый (ныне неподдерживаемый) changeip.mobileproxy.space путь, но это
|
||
ДРУГОЙ хост с ДРУГИМ протоколом ротации (GET, без заголовков).
|
||
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` мог бы
|
||
уйти на ЧУЖОЙ провайдер. rotate_proxy ПЕРЕД любым HTTP-вызовом определяет
|
||
провайдера по urlparse(rotate_url).hostname (https-only, exact match) через
|
||
_rotate_provider_for_host — несовпадение ни с одним известным хостом это ОТКАЗ
|
||
(ok=False, нейтральный reason), а НЕ попытка безголового запроса: смысл ручной
|
||
ротации — конкретный провайдер, молчаливый вызов чужой ручки — это сюрприз
|
||
оператору (он думает "ASocks ротировал", а фактически задел другого
|
||
провайдера), которого проще не допустить, чем потом объяснять админу
|
||
расхождение счётчиков.
|
||
|
||
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.services.proxy_pool import clear_source_bans
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
__all__ = [
|
||
"ALLOWED_MOBILEPROXY_ROTATE_HOST",
|
||
"ALLOWED_ROTATE_HOST",
|
||
"DAILY_ROTATION_LIMIT",
|
||
"MOBILEPROXY_DAILY_ROTATION_LIMIT",
|
||
"RotationResult",
|
||
"rotate_proxy",
|
||
]
|
||
|
||
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
|
||
DAILY_ROTATION_LIMIT = 3
|
||
|
||
# У mobileproxy.space кулдауна/лимита провайдера не обнаружено (эмпирика: 13 ротаций
|
||
# подряд дали 13 уникальных IP). Это наш собственный предохранитель от runaway-цикла
|
||
# ротаций, НЕ ограничение провайдера.
|
||
MOBILEPROXY_DAILY_ROTATION_LIMIT = 200
|
||
|
||
# Таймаут HTTP-вызова ротации (оба провайдера). Пункт задачи требует "~30с".
|
||
_ROTATE_TIMEOUT_S = 30.0
|
||
|
||
# Единственный хост, на который разрешено уходить с ASOCKS_API_TOKEN в заголовке
|
||
# (см. "⛔ Хост-пиннинг" в docstring модуля). scrape_proxies.rotate_url может
|
||
# нести ЧУЖИЕ rotate-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
|
||
ALLOWED_ROTATE_HOST = "api.asocks.com"
|
||
|
||
# Единственный хост mobileproxy.space, поддерживаемый нашей ротацией. Домен
|
||
# changeip.mobileproxy.space из прод-сети не резолвится/не отвечает — НЕ добавлять.
|
||
ALLOWED_MOBILEPROXY_ROTATE_HOST = "aproxy.site"
|
||
|
||
|
||
def _rotate_provider_for_host(url: str) -> str | None:
|
||
"""Определить провайдера ротации по хосту rotate_url (https-only, exact match,
|
||
без исключений на кривом url — см. "⛔ Хост-пиннинг" в docstring модуля).
|
||
|
||
Возвращает "asocks" / "mobileproxy" / None (неизвестный/неразрешённый хост —
|
||
отказ ДО любого HTTP-вызова, чтобы не увести чужой секрет на непроверенный хост).
|
||
"""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except ValueError:
|
||
return None
|
||
if parsed.scheme != "https":
|
||
return None
|
||
if parsed.hostname == ALLOWED_ROTATE_HOST:
|
||
return "asocks"
|
||
if parsed.hostname == ALLOWED_MOBILEPROXY_ROTATE_HOST:
|
||
return "mobileproxy"
|
||
return None
|
||
|
||
|
||
@dataclass
|
||
class RotationResult:
|
||
"""Результат попытки ротации exit-IP одного прокси. reason — ВСЕГДА нейтральный
|
||
(безопасен для HTTP-ответа клиенту), никогда не несёт токен/секреты."""
|
||
|
||
ok: bool
|
||
reason: str | None
|
||
new_ip: str | None = None
|
||
# Сколько quota-consuming попыток остаётся сегодня ПОСЛЕ этой попытки (см. модуль
|
||
# docstring за определением quota-consuming). Для локально отклонённых попыток
|
||
# (no rotate_url/no token) не относится к текущему прокси — просто текущий остаток.
|
||
# Лимит зависит от провайдера (DAILY_ROTATION_LIMIT для ASocks,
|
||
# MOBILEPROXY_DAILY_ROTATION_LIMIT для mobileproxy) — значение по умолчанию тут
|
||
# используется только до определения провайдера, вызывающая ветка всегда
|
||
# подставляет правильное число.
|
||
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
|
||
# mobileproxy-only: секунды на переподключение канала после ротации ("rt" в
|
||
# ответе провайдера) — следующая задача (интеграция в скраперы) ждёт это время
|
||
# перед повторным запросом через прокси. None для ASocks и для неуспешных попыток.
|
||
reconnect_delay_s: float | None = None
|
||
|
||
|
||
def _quota_used_today(db: Session, proxy_id: int) -> int:
|
||
"""Число quota-consuming попыток за последние 24ч (см. docstring модуля).
|
||
|
||
http_status IS NOT NULL AND != 401 — успех И любой не-401 ответ провайдера.
|
||
401 (auth-отсев) и сетевые ошибки (http_status IS NULL) не считаются.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT count(*) AS n
|
||
FROM scrape_proxy_rotations
|
||
WHERE proxy_id = CAST(:proxy_id AS bigint)
|
||
AND rotated_at > now() - interval '24 hours'
|
||
AND http_status IS NOT NULL
|
||
AND http_status != 401
|
||
"""
|
||
),
|
||
{"proxy_id": proxy_id},
|
||
)
|
||
.mappings()
|
||
.fetchone()
|
||
)
|
||
return int(row["n"]) if row is not None else 0
|
||
|
||
|
||
def _record_attempt(
|
||
db: Session,
|
||
proxy_id: int,
|
||
*,
|
||
success: bool,
|
||
http_status: int | None,
|
||
note: str | None,
|
||
) -> None:
|
||
"""Записать попытку ротации в аудит-таблицу. Вызывается ТОЛЬКО когда HTTP-запрос
|
||
к провайдеру реально был сделан (локально отклонённые попытки не пишутся —
|
||
см. модуль docstring)."""
|
||
db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO scrape_proxy_rotations (proxy_id, success, http_status, note)
|
||
VALUES (
|
||
CAST(:proxy_id AS bigint),
|
||
CAST(:success AS boolean),
|
||
CAST(:http_status AS integer),
|
||
CAST(:note AS text)
|
||
)
|
||
"""
|
||
),
|
||
{"proxy_id": proxy_id, "success": success, "http_status": http_status, "note": note},
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _update_exit_ip(db: Session, proxy_id: int, new_ip: str) -> None:
|
||
"""Обновить scrape_proxies.exit_ip после успешной ротации (mobileproxy)."""
|
||
db.execute(
|
||
text(
|
||
"""
|
||
UPDATE scrape_proxies
|
||
SET exit_ip = CAST(:exit_ip AS text), updated_at = now()
|
||
WHERE id = CAST(:proxy_id AS bigint)
|
||
"""
|
||
),
|
||
{"exit_ip": new_ip, "proxy_id": proxy_id},
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _alert_stale_token(proxy_id: int) -> None:
|
||
"""Громкий отказ на 401: logger.error + событие в Sentry/GlitchTip (best-effort).
|
||
|
||
401 значит, что провайдер отверг Authorization-заголовок — токен протух (issue
|
||
#2600 п.5: "Токен — сессионный, однажды протухнет. Это осознанное решение
|
||
владельца"). Молчаливая остановка ротации недопустима — операторы должны узнать
|
||
об этом сразу, а не когда прокси уже забанены неделю.
|
||
"""
|
||
logger.error(
|
||
"proxy_rotation: ASocks REJECTED Authorization (401) for proxy_id=%d — "
|
||
"ASOCKS_API_TOKEN likely EXPIRED, IP rotation is now BLOCKED for this proxy "
|
||
"until the token is refreshed in web-cabinet + env",
|
||
proxy_id,
|
||
)
|
||
try:
|
||
import sentry_sdk
|
||
|
||
sentry_sdk.capture_message(
|
||
f"ASocks rotation token rejected (401) for proxy_id={proxy_id} — "
|
||
"ASOCKS_API_TOKEN expired, IP rotation blocked until refreshed",
|
||
level="error",
|
||
)
|
||
except Exception:
|
||
pass # sentry_sdk not initialised in dev — best-effort only
|
||
|
||
|
||
def _extract_new_ip(resp: httpx.Response) -> str | None:
|
||
"""Best-effort вытащить новый exit-IP из ответа провайдера. Формат ответа
|
||
refresh-ip для безлимитных портов ASocks не документирован (issue #2600 п.5) —
|
||
парсинг заведомо defensive, неудача не является ошибкой ротации."""
|
||
try:
|
||
data: Any = resp.json()
|
||
except Exception:
|
||
return None
|
||
if not isinstance(data, dict):
|
||
return None
|
||
for key in ("new_ip", "ip", "exit_ip"):
|
||
val = data.get(key)
|
||
if val:
|
||
return str(val)
|
||
nested = data.get("data")
|
||
if isinstance(nested, dict):
|
||
for key in ("new_ip", "ip", "exit_ip"):
|
||
val = nested.get(key)
|
||
if val:
|
||
return str(val)
|
||
return None
|
||
|
||
|
||
def _parse_rt(raw: Any) -> float | None:
|
||
"""Распарсить "rt" (секунды переподключения канала) из ответа mobileproxy.
|
||
Формат не документирован — best-effort, неудача не является ошибкой ротации."""
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return float(raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||
"""Сменить exit-IP одного прокси пула — диспетчер по провайдеру (#2600 п.5).
|
||
|
||
Порядок:
|
||
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
||
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
||
3. rotate_url хост определяет провайдера (_rotate_provider_for_host) ДО
|
||
любого HTTP-вызова — секрет провайдера не должен уйти на чужой хост
|
||
(см. "⛔ Хост-пиннинг" в docstring модуля). Неизвестный хост → ok=False.
|
||
4. Известный провайдер → делегируем в _rotate_via_asocks /
|
||
_rotate_via_mobileproxy (детали протокола — там и в docstring модуля).
|
||
|
||
Ни в одном из reason/логов НЕ появляется токен/ключ.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text("SELECT id, rotate_url FROM scrape_proxies WHERE id = CAST(:id AS bigint)"),
|
||
{"id": proxy_id},
|
||
)
|
||
.mappings()
|
||
.fetchone()
|
||
)
|
||
if row is None:
|
||
return RotationResult(ok=False, reason="proxy not found")
|
||
|
||
rotate_url = row["rotate_url"]
|
||
if not rotate_url:
|
||
logger.info(
|
||
"proxy_rotation: proxy_id=%d has no rotate_url — rotation not supported", proxy_id
|
||
)
|
||
return RotationResult(
|
||
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
|
||
)
|
||
|
||
provider = _rotate_provider_for_host(rotate_url)
|
||
|
||
if provider == "asocks":
|
||
return await _rotate_via_asocks(db, proxy_id, rotate_url)
|
||
|
||
if provider == "mobileproxy":
|
||
return await _rotate_via_mobileproxy(db, proxy_id, rotate_url)
|
||
|
||
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
|
||
# ссылки других провайдеров) — отправлять секрет ЛЮБОГО провайдера на
|
||
# непроверенный хост нельзя. Логируем ТОЛЬКО hostname (не полный url — на
|
||
# других провайдерах он несёт их собственный секрет в query-string, тот же
|
||
# класс утечки, что и в rotate_proxy_ip, см. модуль docstring).
|
||
logger.warning(
|
||
"proxy_rotation: proxy_id=%d rotate_url host=%r is not a recognised rotation "
|
||
"provider (allowed: %s, %s) — refusing before any HTTP call to avoid leaking "
|
||
"credentials to it",
|
||
proxy_id,
|
||
urlparse(rotate_url).hostname,
|
||
ALLOWED_ROTATE_HOST,
|
||
ALLOWED_MOBILEPROXY_ROTATE_HOST,
|
||
)
|
||
return RotationResult(
|
||
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
|
||
)
|
||
|
||
|
||
async def _rotate_via_asocks(db: Session, proxy_id: int, rotate_url: str) -> RotationResult:
|
||
"""ASocks refresh-ip (см. docstring модуля § ASocks). rotate_url уже проверен
|
||
вызывающим (_rotate_provider_for_host == "asocks").
|
||
|
||
Порядок:
|
||
1. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||
внятный отказ, ничего не ломается.
|
||
2. Суточный лимит (см. _quota_used_today, DAILY_ROTATION_LIMIT) исчерпан →
|
||
ok=False, отказ БЕЗ обращения к API.
|
||
3. POST rotate_url с Authorization: Bearer <token>, timeout ~30с.
|
||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||
- 401 → громкий отказ (_alert_stale_token) + аудит-запись (НЕ считается
|
||
в лимите), нейтральный reason.
|
||
- Другой 4xx/5xx → аудит-запись (считается в лимите — провайдер прошёл
|
||
auth и ответил своей бизнес-логикой), нейтральный reason.
|
||
- 2xx → аудит-запись success=True (считается в лимите), new_ip best-effort.
|
||
"""
|
||
token = settings.asocks_api_token
|
||
if not token:
|
||
logger.warning(
|
||
"proxy_rotation: ASOCKS_API_TOKEN not configured — proxy_id=%d rotation skipped",
|
||
proxy_id,
|
||
)
|
||
return RotationResult(ok=False, reason="rotation not configured (missing API token)")
|
||
|
||
used = _quota_used_today(db, proxy_id)
|
||
if used >= DAILY_ROTATION_LIMIT:
|
||
logger.warning(
|
||
"proxy_rotation: daily limit reached proxy_id=%d used=%d/%d — skipping API call",
|
||
proxy_id,
|
||
used,
|
||
DAILY_ROTATION_LIMIT,
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason=f"daily rotation limit reached ({DAILY_ROTATION_LIMIT}/day)",
|
||
rotations_remaining_today=0,
|
||
)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
||
resp = await client.post(rotate_url, headers={"Authorization": f"Bearer {token}"})
|
||
except Exception as exc:
|
||
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
|
||
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
|
||
# служебные детали соединения) — только exc_info=True в лог. type(exc).__name__
|
||
# секрета не несёт (это имя класса — ConnectError/ReadTimeout/…) и в note
|
||
# ПОЛЕЗЕН оператору: отличить "не дозвонились" от "дозвонились, зависли".
|
||
logger.warning(
|
||
"proxy_rotation: request failed (no response) proxy_id=%d", proxy_id, exc_info=True
|
||
)
|
||
_record_attempt(
|
||
db,
|
||
proxy_id,
|
||
success=False,
|
||
http_status=None,
|
||
note=f"request failed: {type(exc).__name__}",
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason="rotation request failed (network error)",
|
||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
|
||
)
|
||
|
||
status = resp.status_code
|
||
|
||
if status == 401:
|
||
_alert_stale_token(proxy_id)
|
||
_record_attempt(
|
||
db,
|
||
proxy_id,
|
||
success=False,
|
||
http_status=401,
|
||
note="unauthenticated — token expired/invalid (excluded from daily quota)",
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason="rotation service rejected credentials — alerted, contact operator",
|
||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - used),
|
||
)
|
||
|
||
if status >= 400:
|
||
logger.warning(
|
||
"proxy_rotation: provider returned error proxy_id=%d status=%d", proxy_id, status
|
||
)
|
||
_record_attempt(
|
||
db, proxy_id, success=False, http_status=status, note="provider returned error"
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason=f"rotation request failed (provider status {status})",
|
||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||
)
|
||
|
||
new_ip = _extract_new_ip(resp)
|
||
logger.info("proxy_rotation: rotated proxy_id=%d status=%d new_ip=%s", proxy_id, status, new_ip)
|
||
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
|
||
# Площадки банили СТАРЫЙ exit-IP, а строка бана привязана к proxy_id (#2600 п.2) —
|
||
# после смены адреса она держала бы узел вне выдачи уже без причины, вплоть до 72ч
|
||
# при эскалации. Ротация прошла → история банов этого узла недействительна.
|
||
clear_source_bans(db, proxy_id, reason=f"exit ip rotated (status={status})")
|
||
return RotationResult(
|
||
ok=True,
|
||
reason=None,
|
||
new_ip=new_ip,
|
||
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||
)
|
||
|
||
|
||
async def _rotate_via_mobileproxy(db: Session, proxy_id: int, rotate_url: str) -> RotationResult:
|
||
"""mobileproxy.space (aproxy.site) rotation (см. docstring модуля § mobileproxy).
|
||
rotate_url уже проверен вызывающим (_rotate_provider_for_host == "mobileproxy").
|
||
|
||
Порядок:
|
||
1. Предохранительный суточный лимит (см. _quota_used_today,
|
||
MOBILEPROXY_DAILY_ROTATION_LIMIT) исчерпан → ok=False, отказ БЕЗ
|
||
обращения к API (это наш собственный runaway-guard, не лимит провайдера).
|
||
2. GET rotate_url&format=json, БЕЗ заголовков, timeout ~30с.
|
||
- Сетевая ошибка (нет ответа) → ok=False, аудит-запись http_status=NULL
|
||
(НЕ считается в лимите), нейтральный reason, детали в лог exc_info=True.
|
||
- HTTP 4xx/5xx → аудит-запись (считается в лимите), нейтральный reason.
|
||
- HTTP 2xx, но JSON status != "OK" → аудит-запись success=False (считается
|
||
в лимите — провайдер ответил, просто бизнес-неудача), нейтральный reason.
|
||
- HTTP 2xx и status == "OK" → аудит-запись success=True, new_ip записывается
|
||
в scrape_proxies.exit_ip, бан-история узла снимается (тот же принцип,
|
||
что и у ASocks), rt возвращается вызывающему как reconnect_delay_s.
|
||
"""
|
||
used = _quota_used_today(db, proxy_id)
|
||
if used >= MOBILEPROXY_DAILY_ROTATION_LIMIT:
|
||
logger.warning(
|
||
"proxy_rotation: mobileproxy daily limit reached proxy_id=%d used=%d/%d — "
|
||
"skipping API call",
|
||
proxy_id,
|
||
used,
|
||
MOBILEPROXY_DAILY_ROTATION_LIMIT,
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason=f"daily rotation limit reached ({MOBILEPROXY_DAILY_ROTATION_LIMIT}/day)",
|
||
rotations_remaining_today=0,
|
||
)
|
||
|
||
# proxy_key живёт в query-string самого rotate_url — request_url несёт его
|
||
# так же, как и исходный url. НИКОГДА не логировать/не возвращать request_url
|
||
# или rotate_url целиком (см. "⛔ Токен..." в docstring модуля).
|
||
request_url = httpx.URL(rotate_url).copy_merge_params({"format": "json"})
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=_ROTATE_TIMEOUT_S) as client:
|
||
resp = await client.get(request_url)
|
||
except Exception as exc:
|
||
# Тот же принцип, что и у ASocks выше: str(exc) может нести полный URL
|
||
# (значит и proxy_key) — наружу только exc_info=True в лог,
|
||
# type(exc).__name__ в note (не секрет, полезен оператору).
|
||
logger.warning(
|
||
"proxy_rotation: mobileproxy request failed (no response) proxy_id=%d",
|
||
proxy_id,
|
||
exc_info=True,
|
||
)
|
||
_record_attempt(
|
||
db,
|
||
proxy_id,
|
||
success=False,
|
||
http_status=None,
|
||
note=f"request failed: {type(exc).__name__}",
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason="rotation request failed (network error)",
|
||
rotations_remaining_today=max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - used),
|
||
)
|
||
|
||
status = resp.status_code
|
||
remaining_after = max(0, MOBILEPROXY_DAILY_ROTATION_LIMIT - (used + 1))
|
||
|
||
if status >= 400:
|
||
logger.warning(
|
||
"proxy_rotation: mobileproxy provider returned error proxy_id=%d status=%d",
|
||
proxy_id,
|
||
status,
|
||
)
|
||
_record_attempt(
|
||
db, proxy_id, success=False, http_status=status, note="provider returned error"
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason=f"rotation request failed (provider status {status})",
|
||
rotations_remaining_today=remaining_after,
|
||
)
|
||
|
||
data: Any
|
||
try:
|
||
data = resp.json()
|
||
except Exception:
|
||
data = None
|
||
|
||
provider_status = data.get("status") if isinstance(data, dict) else None
|
||
if provider_status != "OK":
|
||
logger.warning(
|
||
"proxy_rotation: mobileproxy provider reported non-OK status proxy_id=%d status=%r",
|
||
proxy_id,
|
||
provider_status,
|
||
)
|
||
_record_attempt(
|
||
db,
|
||
proxy_id,
|
||
success=False,
|
||
http_status=status,
|
||
note=f"provider status={provider_status!r}",
|
||
)
|
||
return RotationResult(
|
||
ok=False,
|
||
reason="rotation request failed (provider reported failure)",
|
||
rotations_remaining_today=remaining_after,
|
||
)
|
||
|
||
raw_new_ip = data.get("new_ip")
|
||
new_ip = str(raw_new_ip) if raw_new_ip else None
|
||
reconnect_delay_s = _parse_rt(data.get("rt"))
|
||
|
||
logger.info(
|
||
"proxy_rotation: mobileproxy rotated proxy_id=%d status=%d new_ip=%s rt=%s",
|
||
proxy_id,
|
||
status,
|
||
new_ip,
|
||
reconnect_delay_s,
|
||
)
|
||
_record_attempt(db, proxy_id, success=True, http_status=status, note=None)
|
||
if new_ip:
|
||
_update_exit_ip(db, proxy_id, new_ip)
|
||
# Тот же принцип, что и у ASocks (см. docstring модуля): IP сменился → баны
|
||
# площадок на СТАРОМ адресе недействительны.
|
||
clear_source_bans(db, proxy_id, reason=f"exit ip rotated (mobileproxy status={status})")
|
||
return RotationResult(
|
||
ok=True,
|
||
reason=None,
|
||
new_ip=new_ip,
|
||
rotations_remaining_today=remaining_after,
|
||
reconnect_delay_s=reconnect_delay_s,
|
||
)
|