gendesign/tradein-mvp/backend/app/services/proxy_rotation.py
lekss361 a256077211
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
feat(tradein/proxy_rotation): ротация IP через mobileproxy.space (aproxy.site) (#3298)
2026-08-31 11:01:41 +00:00

608 lines
32 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.

"""Ротация 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,
)