feat(tradein/proxy): ротация exit-IP ASocks по бану со счётчиком и громким отказом (#2600)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 2m40s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
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 2m40s
This commit is contained in:
parent
34b346b097
commit
b1563b86cb
6 changed files with 860 additions and 0 deletions
|
|
@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db
|
||||||
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
|
||||||
from app.services import cian_session as cian_session_svc
|
from app.services import cian_session as cian_session_svc
|
||||||
from app.services import domclick_session as domclick_session_svc
|
from app.services import domclick_session as domclick_session_svc
|
||||||
|
from app.services import proxy_rotation as proxy_rotation_svc
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.geocoder import geocode
|
from app.services.geocoder import geocode
|
||||||
from app.services.scheduler import has_running_run
|
from app.services.scheduler import has_running_run
|
||||||
|
|
@ -2889,3 +2890,44 @@ def patch_proxy(
|
||||||
created_at=_iso(row["created_at"]),
|
created_at=_iso(row["created_at"]),
|
||||||
updated_at=_iso(row["updated_at"]),
|
updated_at=_iso(row["updated_at"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Proxy pool: ручная ротация exit-IP по proxy_id (#2600 п.5) ───────────────
|
||||||
|
#
|
||||||
|
# ОТДЕЛЬНО от /scraper/{source}/rotate-ip (выше) — тот работает по env-прокси
|
||||||
|
# mobileproxy для avito/cian/yandex (changeip-ссылка, ротация "на лету" без
|
||||||
|
# лимитов), не трогается. Этот эндпоинт — по proxy_id из пула scrape_proxies
|
||||||
|
# (сейчас это ASocks-порты с суточным лимитом 3/сутки), см.
|
||||||
|
# app.services.proxy_rotation.rotate_proxy.
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyRotateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
reason: str | None = None
|
||||||
|
new_ip: str | None = None
|
||||||
|
rotations_remaining_today: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/proxies/{proxy_id}/rotate", response_model=ProxyRotateResponse)
|
||||||
|
async def rotate_pool_proxy(
|
||||||
|
proxy_id: int,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> ProxyRotateResponse:
|
||||||
|
"""Ручная ротация exit-IP одного прокси пула (#2600 п.5).
|
||||||
|
|
||||||
|
Делегирует в app.services.proxy_rotation.rotate_proxy — читает rotate_url
|
||||||
|
прокси из scrape_proxies, требует ASOCKS_API_TOKEN (settings.asocks_api_token),
|
||||||
|
проверяет суточный лимит (3/сутки, scrape_proxy_rotations) ДО обращения к API.
|
||||||
|
ok=False — ожидаемая бизнес-ситуация (нет rotate_url / нет токена / лимит /
|
||||||
|
провайдер отказал), НЕ HTTPException; reason ВСЕГДА нейтральный, без токена.
|
||||||
|
|
||||||
|
ПОКА без автотриггера по бану (issue #2600 п.2: сигнал бана до пула не
|
||||||
|
доходит — страница-заглушка отдаёт 200) — только этот ручной вызов.
|
||||||
|
"""
|
||||||
|
result = await proxy_rotation_svc.rotate_proxy(db, proxy_id)
|
||||||
|
return ProxyRotateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
reason=result.reason,
|
||||||
|
new_ip=result.new_ip,
|
||||||
|
rotations_remaining_today=result.rotations_remaining_today,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -548,6 +548,18 @@ class Settings(BaseSettings):
|
||||||
proxy_rotate_attempt_timeout_s: float = 8.0
|
proxy_rotate_attempt_timeout_s: float = 8.0
|
||||||
proxy_rotate_attempts: int = 3
|
proxy_rotate_attempts: int = 3
|
||||||
|
|
||||||
|
# ── ASocks pool-proxy rotation (#2600) ───────────────────────────────────
|
||||||
|
# Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip
|
||||||
|
# (app.services.proxy_rotation). Документированный публичный API (GET
|
||||||
|
# /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает —
|
||||||
|
# подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка
|
||||||
|
# веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное
|
||||||
|
# решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy
|
||||||
|
# логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех
|
||||||
|
# прокси недоступна (rotate_proxy возвращает внятный отказ, не падает).
|
||||||
|
# ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе.
|
||||||
|
asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN")
|
||||||
|
|
||||||
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
|
||||||
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
|
||||||
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
|
||||||
|
|
|
||||||
311
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
311
tradein-mvp/backend/app/services/proxy_rotation.py
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
"""Ротация exit-IP прокси ASocks по требованию, со счётчиком и громким отказом (#2600 п.5).
|
||||||
|
|
||||||
|
АДДИТИВНО. НЕ трогает app.services.proxy_pool (pick/lease/health — параллельный
|
||||||
|
PR #2609, конфликт исключён: вся новая логика тут, в новом модуле).
|
||||||
|
|
||||||
|
Контекст (эмпирика, 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), а не молчаливая остановка.
|
||||||
|
|
||||||
|
Суточный лимит и таблица истории (scrape_proxy_rotations, миграция 198):
|
||||||
|
Против лимита 3/сутки считаются ТОЛЬКО попытки, реально дошедшие до провайдера
|
||||||
|
и обработанные им — т.е. любой HTTP-ответ провайдера, КРОМЕ 401. Обоснование:
|
||||||
|
401 — это буквально описание провайдера "Unauthenticated": запрос отсеян на
|
||||||
|
уровне аутентификации ДО обращения к самой логике ротации порта, провайдер не
|
||||||
|
мог засчитать использование ротации тому, кого даже не подтвердил. Сетевые
|
||||||
|
ошибки (таймаут / разрыв соединения — ответа вообще нет) по той же логике не
|
||||||
|
считаются: нет подтверждения, что запрос вообще дошёл до провайдера. Локальные
|
||||||
|
отказы (нет 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) — см. комментарий в app.api.v1.admin.rotate_proxy_ip (~line 2400):
|
||||||
|
httpx-исключения несут полный request URL/детали, поэтому наружу — только
|
||||||
|
нейтральный reason, полные детали — в лог с exc_info=True.
|
||||||
|
|
||||||
|
psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DAILY_ROTATION_LIMIT",
|
||||||
|
"RotationResult",
|
||||||
|
"rotate_proxy",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Лимит провайдера (ASocks, безлимитные порты): 3 ротации в сутки на порт (эмпирика).
|
||||||
|
DAILY_ROTATION_LIMIT = 3
|
||||||
|
|
||||||
|
# Таймаут POST refresh-ip. Пункт задачи требует "~30с".
|
||||||
|
_ROTATE_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
@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) не относится к текущему прокси — просто текущий остаток.
|
||||||
|
rotations_remaining_today: int = DAILY_ROTATION_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
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 _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
|
||||||
|
|
||||||
|
|
||||||
|
async def rotate_proxy(db: Session, proxy_id: int) -> RotationResult:
|
||||||
|
"""Сменить exit-IP одного прокси пула через ASocks refresh-ip (#2600 п.5).
|
||||||
|
|
||||||
|
Порядок:
|
||||||
|
1. proxy_id не найден в scrape_proxies → ok=False, reason нейтральный.
|
||||||
|
2. rotate_url пусто → ok=False, "ротация не поддерживается" (НЕ ошибка).
|
||||||
|
3. ASOCKS_API_TOKEN не задан (settings.asocks_api_token) → ok=False,
|
||||||
|
внятный отказ, ничего не ломается.
|
||||||
|
4. Суточный лимит (см. _quota_used_today) исчерпан → ok=False, отказ БЕЗ
|
||||||
|
обращения к API.
|
||||||
|
5. 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.
|
||||||
|
|
||||||
|
Ни в одном из 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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Ответа не было вообще — не подтверждено, что запрос дошёл до провайдера,
|
||||||
|
# значит квота НЕ тратится. str(exc) НИКОГДА не идёт наружу (может нести
|
||||||
|
# служебные детали соединения) — только exc_info=True в лог.
|
||||||
|
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="request failed (no response)"
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
return RotationResult(
|
||||||
|
ok=True,
|
||||||
|
reason=None,
|
||||||
|
new_ip=new_ip,
|
||||||
|
rotations_remaining_today=max(0, DAILY_ROTATION_LIMIT - (used + 1)),
|
||||||
|
)
|
||||||
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
49
tradein-mvp/backend/data/sql/198_scrape_proxy_rotations.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
-- 198_scrape_proxy_rotations.sql
|
||||||
|
-- Issue #2600 п.5 — ротация exit-IP прокси ASocks по бану, со счётчиком и громким
|
||||||
|
-- отказом. АДДИТИВНО, не трогает scrape_proxies (157_scrape_proxies.sql) кроме
|
||||||
|
-- FK-ссылки; не трогает proxy_pool.py (параллельный PR #2609).
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- Провайдер (ASocks, безлимитные порты) ограничивает ручную ротацию exit-IP тремя
|
||||||
|
-- вызовами в сутки на порт (эмпирика, владелец аккаунта). app.services.proxy_rotation
|
||||||
|
-- должен и проверять этот лимит ПЕРЕД обращением к API, и вести аудит попыток —
|
||||||
|
-- без отдельной таблицы истории лимит негде считать (scrape_proxies хранит только
|
||||||
|
-- текущее состояние, не историю).
|
||||||
|
--
|
||||||
|
-- Semantics:
|
||||||
|
-- Одна строка = одна попытка ротации (успешная ИЛИ неуспешная), но НЕ каждый
|
||||||
|
-- вызов rotate_proxy() пишет строку — локально отклонённые попытки (нет
|
||||||
|
-- rotate_url / нет ASOCKS_API_TOKEN / лимит уже исчерпан) вообще не доходят до
|
||||||
|
-- HTTP-вызова и в таблицу не пишутся (см. app.services.proxy_rotation docstring
|
||||||
|
-- за полным обоснованием "какие попытки считать против лимита").
|
||||||
|
-- http_status NULL = сетевая ошибка (ответа от провайдера не было вообще).
|
||||||
|
--
|
||||||
|
-- Idempotency:
|
||||||
|
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS → повторный прогон
|
||||||
|
-- no-op (auto-apply strict на деплое это требует). Весь файл в BEGIN/COMMIT.
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 157_scrape_proxies.sql (scrape_proxies.id — FK-таргет).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_proxy_rotations (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
proxy_id bigint NOT NULL REFERENCES scrape_proxies (id),
|
||||||
|
rotated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
success boolean NOT NULL,
|
||||||
|
http_status integer,
|
||||||
|
note text
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE scrape_proxy_rotations IS
|
||||||
|
'Аудит + суточный лимит (#2600 п.5) ручных ротаций exit-IP через ASocks '
|
||||||
|
'refresh-ip. Лимит провайдера — 3 попытки/сутки на порт; app.services.'
|
||||||
|
'proxy_rotation._quota_used_today считает только строки с http_status '
|
||||||
|
'IS NOT NULL AND != 401 (реально дошедшие до провайдера) за последние 24ч.';
|
||||||
|
|
||||||
|
-- Проверка суточного лимита + выборка истории по прокси: (proxy_id, rotated_at).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scrape_proxy_rotations_proxy_time
|
||||||
|
ON scrape_proxy_rotations (proxy_id, rotated_at);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- 199_scrape_proxies_asocks_rotate_url.sql
|
||||||
|
-- Issue #2600 п.5 — проставить rotate_url для четырёх ASocks unlimited-портов пула,
|
||||||
|
-- чтобы app.services.proxy_rotation.rotate_proxy имел куда стучаться.
|
||||||
|
--
|
||||||
|
-- WHY:
|
||||||
|
-- scrape_proxies.rotate_url для этих 4 строк сейчас NULL (загружены через
|
||||||
|
-- POST /proxies/bulk без rotate_url). Единственный рабочий способ ротации exit-IP
|
||||||
|
-- для ASocks-безлимитных портов — ручка веб-кабинета
|
||||||
|
-- POST https://api.asocks.com/unlimited-proxy/{portId}/refresh-ip с заголовком
|
||||||
|
-- Authorization: Bearer <ASOCKS_API_TOKEN> (env, НЕ в URL — секретов в миграции
|
||||||
|
-- нет). Документированный публичный GET /v2/proxy/refresh/{portId}?apiKey= для
|
||||||
|
-- безлимитных портов не работает (подтверждено владельцем аккаунта); ротация
|
||||||
|
-- session-суффиксом логина тоже не работает (проверено пробой, три варианта —
|
||||||
|
-- один и тот же exit-IP).
|
||||||
|
--
|
||||||
|
-- Matching (важно — НЕ по id):
|
||||||
|
-- scrape_proxies.id может разъехаться между средами (dev/stage/prod грузятся
|
||||||
|
-- bulk-ручкой независимо) — сопоставляем по адресу host:port, зашитому в конец
|
||||||
|
-- url (scrape_proxies.url — всегда 'scheme://[user:pass@]host:port' БЕЗ пути,
|
||||||
|
-- см. admin.py _mask_proxy_url/urlparse-логику и 157_scrape_proxies.sql) через
|
||||||
|
-- right(url, length(hostport)) = hostport. portId → host:port (проверено
|
||||||
|
-- владельцем аккаунта, issue #2600 п.5):
|
||||||
|
-- 223610715 → 212.8.249.134:10423
|
||||||
|
-- 225031312 → 190.2.145.131:10313
|
||||||
|
-- 231878029 → 175.110.115.153:10492
|
||||||
|
-- 231878030 → 109.236.82.42:11048
|
||||||
|
--
|
||||||
|
-- Idempotency:
|
||||||
|
-- Обычный UPDATE ... WHERE — повторный прогон пишет то же значение, no-op по
|
||||||
|
-- результату. Прокси, которых нет в пуле текущей среды (host:port не найден) —
|
||||||
|
-- 0 строк обновлено, не ошибка. Весь файл в BEGIN/COMMIT.
|
||||||
|
--
|
||||||
|
-- Dependencies:
|
||||||
|
-- 157_scrape_proxies.sql (scrape_proxies.rotate_url).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/223610715/refresh-ip',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE right(url, length(CAST('212.8.249.134:10423' AS text))) = '212.8.249.134:10423';
|
||||||
|
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/225031312/refresh-ip',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE right(url, length(CAST('190.2.145.131:10313' AS text))) = '190.2.145.131:10313';
|
||||||
|
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878029/refresh-ip',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE right(url, length(CAST('175.110.115.153:10492' AS text))) = '175.110.115.153:10492';
|
||||||
|
|
||||||
|
UPDATE scrape_proxies
|
||||||
|
SET rotate_url = 'https://api.asocks.com/unlimited-proxy/231878030/refresh-ip',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE right(url, length(CAST('109.236.82.42:11048' AS text))) = '109.236.82.42:11048';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
388
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal file
388
tradein-mvp/backend/tests/services/test_proxy_rotation.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""Offline-тесты ротации exit-IP ASocks (#2600 п.5).
|
||||||
|
|
||||||
|
Покрытие БЕЗ live-сети/БД: httpx.AsyncClient подменён предсказуемым фейком,
|
||||||
|
FakeSession эмулирует scrape_proxies (одна строка) + scrape_proxy_rotations
|
||||||
|
(append-only список), pytest-asyncio (asyncio_mode=auto, см. pyproject.toml).
|
||||||
|
|
||||||
|
- rotate_url пуст → «не поддерживается», НЕ ошибка, HTTP не дёргается.
|
||||||
|
- ASOCKS_API_TOKEN не задан → внятный отказ, HTTP не дёргается.
|
||||||
|
- 4-я попытка за сутки отклоняется БЕЗ обращения к API (лимит 3/сутки).
|
||||||
|
- Успешная ротация пишет запись в scrape_proxy_rotations (success=True).
|
||||||
|
- 401 → logger.error (громкий отказ) + sentry_sdk.capture_message (мониторинг),
|
||||||
|
аудит-запись пишется, но НЕ считается против суточного лимита.
|
||||||
|
- Токен не появляется ни в RotationResult.reason, ни в note аудит-записи —
|
||||||
|
ни в одном из сценариев (сеть-ошибка, 401, provider 5xx, success).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import proxy_rotation
|
||||||
|
|
||||||
|
SECRET_TOKEN = "asocks-super-secret-token-must-never-leak-1a2b3c"
|
||||||
|
|
||||||
|
# ── stateful fakes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, rows: list[dict[str, Any]]):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def mappings(self) -> _FakeResult:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def fetchone(self) -> dict[str, Any] | None:
|
||||||
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
"""Эмуляция Session: одна строка scrape_proxies + append-only
|
||||||
|
scrape_proxy_rotations, интерпретирует SQL по ключевым фрагментам (тот же
|
||||||
|
паттерн, что tests/services/test_proxy_pool.py)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
proxy_row: dict[str, Any] | None,
|
||||||
|
rotations: list[dict[str, Any]] | None = None,
|
||||||
|
):
|
||||||
|
self.proxy_row = proxy_row
|
||||||
|
self.rotations: list[dict[str, Any]] = rotations or []
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||||
|
sql = str(stmt)
|
||||||
|
p = params or {}
|
||||||
|
|
||||||
|
if "SELECT id, rotate_url FROM scrape_proxies" in sql:
|
||||||
|
if self.proxy_row is None or self.proxy_row["id"] != p["id"]:
|
||||||
|
return _FakeResult([])
|
||||||
|
return _FakeResult([dict(self.proxy_row)])
|
||||||
|
|
||||||
|
if "SELECT count(*) AS n" in sql and "scrape_proxy_rotations" in sql:
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(hours=24)
|
||||||
|
n = sum(
|
||||||
|
1
|
||||||
|
for r in self.rotations
|
||||||
|
if r["proxy_id"] == p["proxy_id"]
|
||||||
|
and r["rotated_at"] > cutoff
|
||||||
|
and r["http_status"] is not None
|
||||||
|
and r["http_status"] != 401
|
||||||
|
)
|
||||||
|
return _FakeResult([{"n": n}])
|
||||||
|
|
||||||
|
if "INSERT INTO scrape_proxy_rotations" in sql:
|
||||||
|
self.rotations.append(
|
||||||
|
{
|
||||||
|
"proxy_id": p["proxy_id"],
|
||||||
|
"success": p["success"],
|
||||||
|
"http_status": p["http_status"],
|
||||||
|
"note": p["note"],
|
||||||
|
"rotated_at": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _FakeResult([])
|
||||||
|
|
||||||
|
raise AssertionError(f"unhandled SQL: {sql}")
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, status_code: int, json_data: dict[str, Any] | None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._json_data = json_data
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
if self._json_data is None:
|
||||||
|
raise ValueError("no json body")
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_async_client(
|
||||||
|
*,
|
||||||
|
response: tuple[int, dict[str, Any] | None] | None,
|
||||||
|
exception: Exception | None,
|
||||||
|
):
|
||||||
|
"""Строит замену httpx.AsyncClient, никогда не бьющую в реальную сеть.
|
||||||
|
|
||||||
|
Ровно один из (response, exception) задан. calls накапливает (url, headers)
|
||||||
|
каждого post() — тест проверяет по ним, был ли вообще HTTP-вызов.
|
||||||
|
"""
|
||||||
|
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 post(self, url: str, headers: dict[str, str] | None = None) -> _FakeResponse:
|
||||||
|
calls.append({"url": url, "headers": headers or {}})
|
||||||
|
if exception is not None:
|
||||||
|
raise exception
|
||||||
|
assert response is not None
|
||||||
|
status, body = response
|
||||||
|
return _FakeResponse(status, body)
|
||||||
|
|
||||||
|
return _FakeClientImpl, calls
|
||||||
|
|
||||||
|
|
||||||
|
def _no_http_allowed():
|
||||||
|
"""httpx.AsyncClient-заглушка, падающая AssertionError при любом post() —
|
||||||
|
для сценариев, где HTTP до провайдера дойти НЕ должно."""
|
||||||
|
|
||||||
|
class _ForbiddenClient:
|
||||||
|
def __init__(self, timeout: float | None = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _ForbiddenClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, *a: object, **kw: object) -> None:
|
||||||
|
raise AssertionError("HTTP call must NOT happen for this scenario")
|
||||||
|
|
||||||
|
return _ForbiddenClient
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_ROTATE_URL = "https://api.asocks.com/unlimited-proxy/1/refresh-ip"
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_row(rotate_url: str | None = _DEFAULT_ROTATE_URL) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "rotate_url": rotate_url}
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_rows(proxy_id: int, n: int, *, http_status: int = 200) -> list[dict[str, Any]]:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"proxy_id": proxy_id,
|
||||||
|
"success": http_status < 400,
|
||||||
|
"http_status": http_status,
|
||||||
|
"note": None,
|
||||||
|
"rotated_at": now - timedelta(minutes=i),
|
||||||
|
}
|
||||||
|
for i in range(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── no rotate_url → not an error ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_rotate_url_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row(rotate_url=None))
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.reason is not None
|
||||||
|
assert "rotat" in result.reason.lower() # human-readable, not a crash
|
||||||
|
assert db.rotations == [] # ничего не писалось — попытки не было
|
||||||
|
|
||||||
|
|
||||||
|
# ── missing token → neutral refusal, no crash ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_token_is_neutral_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", "")
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.reason is not None
|
||||||
|
assert db.rotations == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── daily limit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fourth_attempt_today_rejected_without_api_call(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
# 3 quota-consuming попытки уже сегодня (успешные 200 — засчитываются).
|
||||||
|
db = FakeSession(_proxy_row(), _quota_rows(1, proxy_rotation.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
|
||||||
|
# _no_http_allowed() would have raised AssertionError from within rotate_proxy
|
||||||
|
# if the code had tried an HTTP call — reaching here means it didn't.
|
||||||
|
assert len(db.rotations) == proxy_rotation.DAILY_ROTATION_LIMIT # ничего нового не дописано
|
||||||
|
|
||||||
|
|
||||||
|
# ── success writes history ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_successful_rotation_writes_history_row(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
fake_client, calls = _fake_async_client(response=(200, {"ip": "9.9.9.9"}), exception=None)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.new_ip == "9.9.9.9"
|
||||||
|
assert result.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT - 1
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0]["headers"]["Authorization"] == f"Bearer {SECRET_TOKEN}"
|
||||||
|
|
||||||
|
assert len(db.rotations) == 1
|
||||||
|
row = db.rotations[0]
|
||||||
|
assert row["success"] is True
|
||||||
|
assert row["http_status"] == 200
|
||||||
|
assert db.commits >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── 401 → loud failure ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_401_logs_error_and_alerts_monitoring_excluded_from_quota(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
fake_client, calls = _fake_async_client(
|
||||||
|
response=(401, {"success": False, "message": "Unauthenticated"}), exception=None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
sentry_calls: list[tuple[str, str | None]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sentry_sdk.capture_message",
|
||||||
|
lambda msg, level=None: sentry_calls.append((msg, level)),
|
||||||
|
)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert len(calls) == 1 # запрос реально ушёл
|
||||||
|
|
||||||
|
# громкий отказ: и лог, и мониторинг — не молчаливая остановка
|
||||||
|
error_records = [r for r in caplog.records if r.levelno == logging.ERROR]
|
||||||
|
assert any("401" in r.getMessage() for r in error_records)
|
||||||
|
assert len(sentry_calls) == 1
|
||||||
|
assert sentry_calls[0][1] == "error"
|
||||||
|
|
||||||
|
# аудит записан, но 401 НЕ считается против суточного лимита (см. модуль
|
||||||
|
# docstring: auth-отсев до провайдера, лимит на его стороне не тратится).
|
||||||
|
assert len(db.rotations) == 1
|
||||||
|
assert db.rotations[0]["http_status"] == 401
|
||||||
|
assert db.rotations[0]["success"] is False
|
||||||
|
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
second = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
# 401 не съел лимит — снова полный DAILY_ROTATION_LIMIT доступен
|
||||||
|
assert second.rotations_remaining_today == proxy_rotation.DAILY_ROTATION_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
# ── token never leaks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_never_appears_in_reason_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
fake_client, _ = _fake_async_client(response=(200, {"ip": "1.1.1.1"}), exception=None)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
assert SECRET_TOKEN not in (result.reason or "")
|
||||||
|
assert SECRET_TOKEN not in (result.new_ip or "")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_never_appears_in_reason_on_401(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr("sentry_sdk.capture_message", lambda *a, **kw: None)
|
||||||
|
fake_client, _ = _fake_async_client(
|
||||||
|
response=(401, {"message": "Unauthenticated"}), exception=None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
assert SECRET_TOKEN not in (result.reason or "")
|
||||||
|
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_never_appears_in_reason_on_network_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""httpx-исключения могут нести полный request-контекст (URL/детали) —
|
||||||
|
прецедент утечки: app.api.v1.admin.rotate_proxy_ip (~line 2400). Здесь токен
|
||||||
|
живёт только в headers (не в URL), но проверяем end-to-end: даже если
|
||||||
|
exception-текст содержит секрет (симулируем это явно), наружу он не идёт."""
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
boom = httpx.ConnectError(f"connection failed while POSTing token={SECRET_TOKEN}")
|
||||||
|
fake_client, _ = _fake_async_client(response=None, exception=boom)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert SECRET_TOKEN not in (result.reason or "")
|
||||||
|
assert all(SECRET_TOKEN not in (r["note"] or "") for r in db.rotations)
|
||||||
|
# сетевая ошибка не подтверждает, что провайдер обработал попытку → квота не тратится
|
||||||
|
assert db.rotations[0]["http_status"] is None
|
||||||
|
assert proxy_rotation._quota_used_today(db, 1) == 0 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_never_appears_on_provider_error_status(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
fake_client, _ = _fake_async_client(
|
||||||
|
response=(500, {"message": "internal error"}), exception=None
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", fake_client)
|
||||||
|
|
||||||
|
db = FakeSession(_proxy_row())
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert SECRET_TOKEN not in (result.reason or "")
|
||||||
|
# провайдер прошёл auth и ответил своей ошибкой (500) — засчитывается в квоту
|
||||||
|
assert db.rotations[0]["http_status"] == 500
|
||||||
|
assert proxy_rotation._quota_used_today(db, 1) == 1 # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ── proxy not found ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_proxy_id_returns_neutral_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(proxy_rotation.settings, "asocks_api_token", SECRET_TOKEN)
|
||||||
|
monkeypatch.setattr(proxy_rotation.httpx, "AsyncClient", _no_http_allowed())
|
||||||
|
|
||||||
|
db = FakeSession(None)
|
||||||
|
result = await proxy_rotation.rotate_proxy(db, 999) # type: ignore[arg-type]
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.reason is not None
|
||||||
Loading…
Add table
Reference in a new issue