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

This commit is contained in:
bot-backend 2026-08-01 21:41:12 +03:00
parent 34b346b097
commit b1563b86cb
6 changed files with 860 additions and 0 deletions

View file

@ -70,6 +70,7 @@ from app.core.db import SessionLocal, get_db
from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
from app.services import cian_session as cian_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.geocoder import geocode
from app.services.scheduler import has_running_run
@ -2889,3 +2890,44 @@ def patch_proxy(
created_at=_iso(row["created_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,
)

View file

@ -548,6 +548,18 @@ class Settings(BaseSettings):
proxy_rotate_attempt_timeout_s: float = 8.0
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,
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.

View 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)),
)

View 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;

View file

@ -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;

View 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