feat(tradein/proxy_rotation): ротация IP через mobileproxy.space (aproxy.site) #3298

Merged
lekss361 merged 1 commit from feat/mobileproxy-rotation-provider into main 2026-08-31 11:01:42 +00:00
2 changed files with 506 additions and 66 deletions

View file

@ -1,8 +1,13 @@
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
"""Ротация 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=)
для безлимитных портов НЕ работает.
@ -19,12 +24,34 @@ PR #2609, конфликт исключён: вся новая логика ту
Когда это случится, провайдер ответит 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/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
и обработанные им т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
401 это буквально описание провайдера "Unauthenticated": запрос отсеян на
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
Против лимита (3/сутки ASocks, 200/сутки mobileproxy) считаются ТОЛЬКО попытки,
реально дошедшие до провайдера и обработанные им т.е. любой HTTP-ответ
провайдера, КРОМЕ 401. Обоснование: 401 это буквально описание провайдера
"Unauthenticated": запрос отсеян на уровне аутентификации ДО обращения к самой
логике ротации порта, провайдер не мог засчитать использование ротации тому,
кого даже не подтвердил (у mobileproxy авторизации нет вовсе, поэтому 401 для
него не ожидается, но правило проверки общее для обоих провайдеров). Сетевые
ошибки (таймаут / разрыв соединения ответа вообще нет) по той же логике не
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
отказы (нет rotate_url / нет токена / лимит уже исчерпан) до HTTP-вызова не
@ -35,27 +62,27 @@ PR #2609, конфликт исключён: вся новая логика ту
если провайдер прошёл auth и ответил бизнес-ошибкой, запрос точно дошёл до
реальной rotate-логики и мог быть учтён в лимите на его стороне).
Токен никогда не должен появиться в возвращаемом клиенту reason, в тексте
исключения, ни в одной записи scrape_proxy_rotations. Прецедент утечки через
str(exc) тот же паттерн, что закрывал (до удаления #2616 шаг 3) changeip-путь
admin.rotate_proxy_ip: httpx-исключения несут полный request URL/детали,
поэтому наружу только нейтральный reason, полные детали в лог с exc_info=True.
Токен/ключ провайдера никогда не должен появиться в возвращаемом клиенту
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 колонка
НЕОДНОРОДНА часть строк пула (id 3/4/5 на проде) несёт mobileproxy changeip-
ссылки (`https://changeip.mobileproxy.space/?proxy_key=<секрет mobileproxy>`,
тот же формат, что читал удалённый #2616 шаг 2/3 admin.rotate_proxy_ip /
Settings.avito_proxy_rotate_url), не ASocks.
Без явной проверки хоста наш `Authorization: Bearer <ASOCKS_API_TOKEN>` ушёл бы
на ЧУЖОЙ провайдер (mobileproxy) плюс сам GET/POST по их changeip, вероятно,
реально ротирует ИХ IP и тратит ИХ суточный лимит, а мы бы записали это как
успех ASocks. rotate_proxy ПЕРЕД любым HTTP-вызовом проверяет
urlparse(rotate_url).hostname == ALLOWED_ROTATE_HOST (https-only) несовпадение
это ОТКАЗ (ok=False, нейтральный reason), а НЕ попытка безголового запроса без
Authorization: смысл ручной ротации конкретный провайдер (ASocks), молчаливый
вызов чужой ручки без авторизации это сюрприз оператору (он думает "ASocks
ротировал", а фактически задел mobileproxy), которого проще не допустить, чем
потом объяснять админу расхождение счётчиков.
НЕОДНОРОДНА часть строк пула несёт 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.
"""
@ -77,8 +104,10 @@ 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",
]
@ -86,23 +115,42 @@ __all__ = [
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
DAILY_ROTATION_LIMIT = 3
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
# У 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 может
# нести ЧУЖИЕ changeip-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
# нести ЧУЖИЕ rotate-ссылки (mobileproxy и т.п.) — сравнение ДО HTTP-вызова.
ALLOWED_ROTATE_HOST = "api.asocks.com"
# Единственный хост mobileproxy.space, поддерживаемый нашей ротацией. Домен
# changeip.mobileproxy.space из прод-сети не резолвится/не отвечает — НЕ добавлять.
ALLOWED_MOBILEPROXY_ROTATE_HOST = "aproxy.site"
def _is_allowed_rotate_url(url: str) -> bool:
"""https-only + hostname точно ALLOWED_ROTATE_HOST (регистронезависимо —
urlparse().hostname уже лоуеркейзит). Не бросает исключений на кривом url."""
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 False
return parsed.scheme == "https" and parsed.hostname == ALLOWED_ROTATE_HOST
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
@ -116,7 +164,15 @@ class RotationResult:
# Сколько 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:
@ -173,6 +229,21 @@ def _record_attempt(
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).
@ -222,29 +293,30 @@ def _extract_new_ip(resp: httpx.Response) -> str | None:
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 одного прокси пула через ASocks refresh-ip (#2600 п.5).
"""Сменить exit-IP одного прокси пула — диспетчер по провайдеру (#2600 п.5).
Порядок:
1. proxy_id не найден в scrape_proxies ok=False, reason нейтральный.
2. rotate_url пусто ok=False, "ротация не поддерживается" (НЕ ошибка).
3. rotate_url хост != ALLOWED_ROTATE_HOST (https://api.asocks.com) ok=False
ДО HTTP-вызова токен не должен уйти на чужой провайдер (mobileproxy
changeip и т.п. в этой же колонке пула, см. "⛔ Хост-пиннинг" в модуле).
4. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) ok=False,
внятный отказ, ничего не ломается.
5. Суточный лимит (см. _quota_used_today) исчерпан ok=False, отказ БЕЗ
обращения к API.
6. 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.
3. rotate_url хост определяет провайдера (_rotate_provider_for_host) ДО
любого HTTP-вызова секрет провайдера не должен уйти на чужой хост
(см. "⛔ Хост-пиннинг" в docstring модуля). Неизвестный хост ok=False.
4. Известный провайдер делегируем в _rotate_via_asocks /
_rotate_via_mobileproxy (детали протокола там и в docstring модуля).
Ни в одном из reason/логов НЕ появляется токен.
Ни в одном из reason/логов НЕ появляется токен/ключ.
"""
row = (
db.execute(
@ -266,24 +338,51 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
ok=False, reason="rotation not supported for this proxy (no rotate_url configured)"
)
if not _is_allowed_rotate_url(rotate_url):
# scrape_proxies.rotate_url колонка неоднородна (другие строки пула несут
# mobileproxy changeip-ссылки с ИХ секретом) — отправлять наш
# Authorization: Bearer <ASOCKS_API_TOKEN> на непроверенный хост нельзя.
# Логируем ТОЛЬКО hostname (не полный url — на других провайдерах он
# несёт их собственный секрет в query-string, тот же класс утечки, что
# и в rotate_proxy_ip, см. модуль docstring).
logger.warning(
"proxy_rotation: proxy_id=%d rotate_url host=%r is not the allowed ASocks host "
"(%s) — refusing before any HTTP call to avoid leaking the token to it",
proxy_id,
urlparse(rotate_url).hostname,
ALLOWED_ROTATE_HOST,
)
return RotationResult(
ok=False, reason="rotation not supported for this proxy (unexpected rotate host)"
)
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(
@ -374,3 +473,136 @@ async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
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,
)

View file

@ -70,6 +70,7 @@ class FakeSession:
# адреса недействителен), см. proxy_pool.clear_source_bans.
self.source_bans: list[dict[str, Any]] = source_bans or []
self.commits = 0
self.exit_ip_updates: list[tuple[int, str]] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
@ -109,6 +110,12 @@ class FakeSession:
self.source_bans = [b for b in self.source_bans if b not in cleared]
return _FakeResult([{"source": b["source"]} for b in cleared])
if "UPDATE scrape_proxies" in sql and "exit_ip" in sql:
self.exit_ip_updates.append((p["proxy_id"], p["exit_ip"]))
if self.proxy_row is not None and self.proxy_row["id"] == p["proxy_id"]:
self.proxy_row["exit_ip"] = p["exit_ip"]
return _FakeResult([])
raise AssertionError(f"unhandled SQL: {sql}")
def commit(self) -> None:
@ -179,10 +186,45 @@ def _no_http_allowed():
async def post(self, *a: object, **kw: object) -> None:
raise AssertionError("HTTP call must NOT happen for this scenario")
async def get(self, *a: object, **kw: object) -> None:
raise AssertionError("HTTP call must NOT happen for this scenario")
return _ForbiddenClient
def _fake_async_client_get(
*,
response: tuple[int, dict[str, Any] | None] | None,
exception: Exception | None,
):
"""Аналог _fake_async_client, но для GET (mobileproxy — без Authorization,
секрет живёт в URL). calls накапливает str(url) каждого get()."""
calls: list[dict[str, Any]] = []
class _FakeClientImpl:
def __init__(self, timeout: float | None = None) -> None:
self.timeout = timeout
async def __aenter__(self) -> _FakeClientImpl:
return self
async def __aexit__(self, *exc: object) -> bool:
return False
async def get(self, url: object) -> _FakeResponse:
calls.append({"url": str(url)})
if exception is not None:
raise exception
assert response is not None
status, body = response
return _FakeResponse(status, body)
return _FakeClientImpl, calls
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
MOBILEPROXY_KEY = "deadbeef00112233deadbeef00112233"
_MOBILEPROXY_ROTATE_URL = f"https://aproxy.site/?proxy_key={MOBILEPROXY_KEY}"
# (name, rotate_url, response=(status, json_body)|None, exception|None) — ровно один
# из response/exception задан, либо оба None (локальный отказ, HTTP не идёт).
@ -557,3 +599,169 @@ async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.Mo
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
assert result.ok is False
assert result.reason is not None
# ── mobileproxy.space (aproxy.site) ─────────────────────────────────────────
async def test_mobileproxy_success_writes_history_and_exit_ip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_client, calls = _fake_async_client_get(
response=(200, {"status": "OK", "code": 200, "new_ip": "128.71.225.59", "rt": "2.41"}),
exception=None,
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert result.new_ip == "128.71.225.59"
assert result.reconnect_delay_s == pytest.approx(2.41)
assert (
result.rotations_remaining_today
== proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT - 1
)
assert len(calls) == 1
assert "format=json" in calls[0]["url"]
assert len(db.rotations) == 1
assert db.rotations[0]["success"] is True
assert db.rotations[0]["http_status"] == 200
assert db.exit_ip_updates == [(1, "128.71.225.59")]
async def test_mobileproxy_success_clears_source_bans(monkeypatch: pytest.MonkeyPatch) -> None:
fake_client, _calls = _fake_async_client_get(
response=(200, {"status": "OK", "new_ip": "9.9.9.9", "rt": "3.1"}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
source_bans=[{"proxy_id": 1, "source": "avito"}],
)
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is True
assert db.source_bans == []
async def test_mobileproxy_non_ok_status_is_failure_and_consumes_quota(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HTTP 200, но body.status != "OK" — провал, даже без транспортной ошибки.
Провайдер ответил (транспортно дошли) попытка засчитывается в предохранитель."""
fake_client, _calls = _fake_async_client_get(
response=(200, {"status": "FAIL", "code": 400}), exception=None
)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert result.new_ip is None
assert db.exit_ip_updates == []
assert len(db.rotations) == 1
assert db.rotations[0]["success"] is False
assert db.rotations[0]["http_status"] == 200
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
async def test_mobileproxy_http_error_status_is_failure(monkeypatch: pytest.MonkeyPatch) -> None:
fake_client, _calls = _fake_async_client_get(response=(503, None), exception=None)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations[0]["http_status"] == 503
async def test_mobileproxy_network_error_does_not_consume_quota(
monkeypatch: pytest.MonkeyPatch,
) -> None:
boom = httpx.ConnectError(f"connection failed for proxy_key={MOBILEPROXY_KEY}")
fake_client, _calls = _fake_async_client_get(response=None, exception=boom)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations[0]["http_status"] is None
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
assert MOBILEPROXY_KEY not in (result.reason or "")
assert MOBILEPROXY_KEY not in (db.rotations[0]["note"] or "")
async def test_mobileproxy_daily_limit_rejected_without_api_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
db = FakeSession(
_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL),
_quota_rows(1, proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT),
)
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert "limit" in (result.reason or "").lower() or "лимит" in (result.reason or "").lower()
assert result.rotations_remaining_today == 0
assert len(db.rotations) == proxy_rotation.MOBILEPROXY_DAILY_ROTATION_LIMIT
async def test_mobileproxy_key_never_leaks_in_reason_or_db_or_logs(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""Проверка секретной гигиены для proxy_key — тот же класс требований, что и
для ASOCKS_API_TOKEN (см. модуль docstring): reason, note аудит-записи и
аргументы logger.*() не должны содержать ключ ни в одном сценарии."""
scenarios: list[tuple[str, tuple[int, dict[str, Any] | None] | None, Exception | None]] = [
("success", (200, {"status": "OK", "new_ip": "1.1.1.1", "rt": "2.0"}), None),
("non_ok", (200, {"status": "FAIL"}), None),
("http_error", (500, {"message": "err"}), None),
(
"network_error",
None,
httpx.ConnectError(f"boom proxy_key={MOBILEPROXY_KEY}"),
),
]
for name, response, exception in scenarios:
fake_client, _ = _fake_async_client_get(response=response, exception=exception)
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
db = FakeSession(_proxy_row(rotate_url=_MOBILEPROXY_ROTATE_URL))
with caplog.at_level(logging.DEBUG):
caplog.clear()
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert MOBILEPROXY_KEY not in (result.reason or ""), name
for row in db.rotations:
assert MOBILEPROXY_KEY not in (row["note"] or ""), name
for record in caplog.records:
assert MOBILEPROXY_KEY not in record.getMessage(), (
f"scenario={name}: proxy_key leaked into log message args"
)
async def test_mobileproxy_unknown_query_shape_still_masks_url_in_refusal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Хост-пиннинг: неизвестный хост, похожий на mobileproxy по форме query
(проверка того, что дефолтный отказ путь тоже не светит секрет из url)."""
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
foreign_url = f"https://changeip.mobileproxy.space/?proxy_key={MOBILEPROXY_KEY}"
db = FakeSession(_proxy_row(rotate_url=foreign_url))
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
assert result.ok is False
assert db.rotations == []
assert MOBILEPROXY_KEY not in (result.reason or "")