gendesign/tradein-mvp/backend/tests/test_scrape_pipeline_proxy_pool.py
bot-backend 47880bfab6
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
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 1m42s
fix(tradein/scrapers): wire proxy_pool into Avito legacy pipeline, rotate on ban (#2202)
Боевой оркестратор (scrape_pipeline.py) читал только статический
settings.scraper_proxy_url — ни один боевой скрейпер не использовал
proxy_pool/scrape_proxies (git grep "acquire(" = 0 хитов). Бан на общем
IP выносил avito+cian+yandex+domklik разом.

Подключён pool-aware proxy acquisition/rotation в 3 curl-режим Avito
entry points (run_avito_pipeline, run_avito_city_sweep,
run_avito_newbuilding_sweep), за флагом use_proxy_pool_curl (default
False — ship-dark, поведение не меняется пока явно не включат):
- _pool_proxy_dict(): health-filtered acquire() lease, fallback на
  статический прокси при пустом пуле/ошибке/флаге off.
- _rotate_pool_proxy(): на бан — mark_health(ok=False) → acquire()
  замены → ТОЛЬКО ПОСЛЕ release(current_lease). Порядок принципиален:
  release ДО acquire() позволял пулу молча вернуть тот же только что
  забаненный прокси (один бан не достигает MAX_CONSECUTIVE_FAILS=3,
  leased_by IS NULL фильтр не исключал бы его) — найдено на pre-push
  review, воспроизведено и закрыто регрессионным тестом с CURRENT id
  ниже GOOD id (оригинальный тест проходил случайно из-за id tie-break).
- run_id прокинут в acquire() на обоих sweep call sites — scrape_proxies
  .leased_by несёт реальный run id для диагностики, не NON_RUN_LEASE_MARKER.

Cian/Yandex/DomClick (browser-mode, отдельный сервис) и
run_avito_full_load (использует AvitoScraper напрямую, много вызывающих
кода за пределами scrape_pipeline) — вне скоупа, задокументировано как
follow-up в PR description.

Refs #2202
2026-07-04 00:45:25 +03:00

408 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for scrape_pipeline.py proxy-pool wiring (#2202).
Issue #2202: боевые скрейперы (scrape_pipeline.py Avito orchestrator) читали только
статический settings.scraper_proxy_url — один mobile-IP на все прогоны, SPOF. Этот
файл покрывает новые pool-aware helpers:
- _pool_proxy_dict: acquire() health-filtered lease за флагом USE_PROXY_POOL_CURL,
fallback на статический env при флаге off / пустом пуле / ошибке.
- _rotate_pool_proxy: переключение на ДРУГОЙ здоровый прокси из пула при бане
(mark_health(False)+release текущего, acquire() следующего, мутация session.proxies).
- _rotate_avito_proxy: единая точка ротации — пул first, changeip fallback.
Acceptance-тест (issue AC4): пул с 1 «забаненным» прокси — ротация выбирает
СЛЕДУЮЩИЙ здоровый, не зависший на забаненном и не переиспользующий только что
провалившийся lease раньше более свежего здорового кандидата.
Offline: FakeSession эмулирует scrape_proxies-таблицу (тот же паттерн, что
tests/services/test_proxy_pool.py), без live БД/сети.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from app.services import proxy_pool
from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS
from app.services.scrape_pipeline import (
_pool_proxy_dict,
_release_pool_lease,
_rotate_avito_proxy,
_rotate_pool_proxy,
)
# ── stateful fake session (mirrors tests/services/test_proxy_pool.py) ─────────
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
def all(self) -> list[dict[str, Any]]:
return list(self._rows)
class FakeSession:
"""Эмуляция Session поверх in-memory списка scrape_proxies-строк."""
def __init__(self, rows: list[dict[str, Any]]):
self.rows = rows
def _by_id(self, pid: int) -> dict[str, Any] | None:
return next((r for r in self.rows if r["id"] == pid), None)
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
sql = str(stmt)
p = params or {}
if "FOR UPDATE SKIP LOCKED" in sql: # acquire SELECT
provider = p["provider"]
max_fails = p["max_fails"]
cands = [
r
for r in self.rows
if r["enabled"]
and r["consecutive_fails"] < max_fails
and r["provider_affinity"] in (provider, "any")
and r["leased_by"] is None
]
cands.sort(
key=lambda r: (
r["last_ok_at"] is None,
r["last_ok_at"] or datetime.min.replace(tzinfo=UTC),
r["id"],
)
)
return _FakeResult(cands[:1])
if "SET leased_by = CAST(:run_id" in sql: # acquire lease UPDATE
row = self._by_id(p["id"])
if row is not None:
row["leased_by"] = p["run_id"]
row["leased_at"] = datetime.now(UTC)
return _FakeResult([])
if "SET leased_by = NULL" in sql: # release
row = self._by_id(p["id"])
if row is not None:
row["leased_by"] = None
row["leased_at"] = None
return _FakeResult([])
if "SET consecutive_fails = 0" in sql: # mark_health ok
row = self._by_id(p["id"])
if row is not None:
row["consecutive_fails"] = 0
row["exit_ip"] = p["exit_ip"]
row["latency_ms"] = p["latency_ms"]
row["last_ok_at"] = datetime.now(UTC)
return _FakeResult([])
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
row = self._by_id(p["id"])
if row is not None:
row["consecutive_fails"] += 1
if row["consecutive_fails"] >= p["disable_threshold"]:
row["enabled"] = False
return _FakeResult([])
raise AssertionError(f"unhandled SQL: {sql}")
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
def _proxy(
pid: int,
*,
affinity: str = "avito",
enabled: bool = True,
fails: int = 0,
leased_by: int | None = None,
last_ok_at: datetime | None = None,
) -> dict[str, Any]:
return {
"id": pid,
"url": f"http://u:p@h{pid}:8080",
"kind": "http",
"rotate_url": None,
"provider_affinity": affinity,
"enabled": enabled,
"consecutive_fails": fails,
"leased_by": leased_by,
"leased_at": None,
"last_ok_at": last_ok_at,
"exit_ip": None,
"latency_ms": None,
}
def _mock_settings(*, use_proxy_pool_curl: bool, scraper_proxy_url: str | None = None):
return SimpleNamespace(
use_proxy_pool_curl=use_proxy_pool_curl,
scraper_proxy_url=scraper_proxy_url,
)
# ── _pool_proxy_dict ────────────────────────────────────────────────────────
def test_pool_proxy_dict_falls_back_when_flag_off():
db = FakeSession([_proxy(1)])
with patch(
"app.services.scrape_pipeline.settings",
_mock_settings(use_proxy_pool_curl=False, scraper_proxy_url="http://env:3128"),
):
proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type]
assert proxies == {"http": "http://env:3128", "https": "http://env:3128"}
assert lease is None
def test_pool_proxy_dict_returns_lease_when_flag_on_and_healthy():
db = FakeSession([_proxy(1)])
with patch(
"app.services.scrape_pipeline.settings",
_mock_settings(use_proxy_pool_curl=True),
):
proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type]
assert lease is not None and lease.id == 1
assert proxies == {"http": "http://u:p@h1:8080", "https": "http://u:p@h1:8080"}
assert db._by_id(1)["leased_by"] is not None # lease проставлен
def test_pool_proxy_dict_falls_back_when_pool_empty():
db = FakeSession([]) # пустой пул
with patch(
"app.services.scrape_pipeline.settings",
_mock_settings(use_proxy_pool_curl=True, scraper_proxy_url="http://env:3128"),
):
proxies, lease = _pool_proxy_dict(db, "avito") # type: ignore[arg-type]
assert lease is None
assert proxies == {"http": "http://env:3128", "https": "http://env:3128"}
# ── _rotate_pool_proxy — acceptance-тест AC4 ───────────────────────────────
@pytest.mark.asyncio
async def test_rotate_pool_proxy_skips_banned_picks_next_healthy():
"""#2202 AC4: пул с 1 забаненным прокси — ротация выбирает следующий здоровый.
BAD (id=1) — уже забанен (fails>=MAX_CONSECUTIVE_FAILS), исключён acquire() на
уровне SQL-фильтра. CURRENT (id=3) — прокси активного прогона, только что словивший
бан (403/429) — вызывающий код зовёт rotate. GOOD (id=2) — свободный здоровый,
единственный валидный кандидат после того как CURRENT будет помечен/освобождён.
NB (#2202 MAJOR-fix): гарантия исключения CURRENT из acquire() держится на том,
что release(current_lease) вызывается ПОСЛЕ acquire(), не до — пока current_lease
leased_by-занят, SQL `leased_by IS NULL` исключает его вне зависимости от id/
last_ok_at сортировки. См. зеркальный тест ниже
(test_rotate_pool_proxy_skips_banned_picks_next_healthy_current_id_lower) —
та же гарантия при CURRENT id НИЖЕ GOOD id (иначе тай-брейк по id в ORDER BY
"last_ok_at NULLS LAST, id" мог случайно фейворить эту фикстуру).
"""
db = FakeSession(
[
_proxy(1, fails=MAX_CONSECUTIVE_FAILS), # BAD — уже забанен
_proxy(2, fails=0), # GOOD — ожидаемый выбор
_proxy(3, fails=0, leased_by=999), # CURRENT — в работе, банится сейчас
]
)
current_lease = proxy_pool.ProxyLease(
id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None
)
session = SimpleNamespace(proxies=None)
new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type]
assert new_lease is not None
assert new_lease.id == 2 # GOOD, не BAD (excluded) и не CURRENT (только что провален)
assert session.proxies == {"http": "http://u:p@h2:8080", "https": "http://u:p@h2:8080"}
# CURRENT (id=3) помечен нездоровым и освобождён
assert db._by_id(3)["consecutive_fails"] == 1
assert db._by_id(3)["leased_by"] is None
# BAD (id=1) не тронут этой операцией
assert db._by_id(1)["consecutive_fails"] == MAX_CONSECUTIVE_FAILS
# GOOD (id=2) теперь зализан под новую ротацию
assert db._by_id(2)["leased_by"] is not None
@pytest.mark.asyncio
async def test_rotate_pool_proxy_skips_banned_picks_next_healthy_current_id_lower():
"""#2202 AC4 regression guard (MAJOR-fix): гарантия исключения current_lease НЕ
должна зависеть от того, чей id меньше в фикстуре.
Зеркало test_rotate_pool_proxy_skips_banned_picks_next_healthy с CURRENT/GOOD id
в обратном порядке: CURRENT=1 < GOOD=2 (оригинальный тест имел CURRENT=3 > GOOD=2,
поэтому "тай-брейк по id" в ORDER BY last_ok_at NULLS LAST, id случайно совпадал с
правильным ответом и не доказывал реальную гарантию — см. review-репро на #2202).
До фикса (release(current_lease) ДО acquire()): CURRENT освобождается, попадает в
leased_by IS NULL кандидаты с fails=1 (< MAX_CONSECUTIVE_FAILS=3, т.е. "здоров"),
и при равном last_ok_at (оба NULL) выигрывает тай-брейк по id (1 < 2) — acquire()
возвращает ТОТ ЖЕ прокси, который только что забанили. Этот тест ловит именно этот
регресс: если release() снова передвинуть перед acquire(), new_lease.id будет 1
(CURRENT), а не 2 (GOOD), и assert упадёт.
"""
db = FakeSession(
[
_proxy(1, fails=0, leased_by=999), # CURRENT — id НИЖЕ GOOD, банится сейчас
_proxy(2, fails=0), # GOOD — единственный здоровый свободный кандидат
]
)
current_lease = proxy_pool.ProxyLease(
id=1, url="http://u:p@h1:8080", kind="http", rotate_url=None
)
session = SimpleNamespace(proxies=None)
new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type]
assert new_lease is not None
assert new_lease.id == 2 # GOOD — НЕ CURRENT, несмотря на id=1 < id=2
assert session.proxies == {"http": "http://u:p@h2:8080", "https": "http://u:p@h2:8080"}
# CURRENT (id=1) помечен нездоровым и освобождён (после успешной ротации)
assert db._by_id(1)["consecutive_fails"] == 1
assert db._by_id(1)["leased_by"] is None
# GOOD (id=2) теперь зализан под новую ротацию
assert db._by_id(2)["leased_by"] is not None
@pytest.mark.asyncio
async def test_rotate_pool_proxy_exhausted_returns_none():
"""Все кандидаты нездоровы после ротации → None (caller падает на changeip).
BAD (id=1) уже забанен. CURRENT (id=3) на MAX_CONSECUTIVE_FAILS-1 fails — ЭТА
ротация (mark_health ok=False) толкает его ровно до порога → тоже исключается
из acquire(). Ни одного здорового кандидата не остаётся.
"""
db = FakeSession(
[
_proxy(1, fails=MAX_CONSECUTIVE_FAILS), # BAD — уже забанен
_proxy(3, fails=MAX_CONSECUTIVE_FAILS - 1, leased_by=999), # CURRENT — на грани
]
)
current_lease = proxy_pool.ProxyLease(
id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None
)
session = SimpleNamespace(proxies=None)
new_lease = await _rotate_pool_proxy(db, "avito", session, current_lease) # type: ignore[arg-type]
assert new_lease is None
assert session.proxies is None # не тронут — caller остаётся на старом/changeip
assert db._by_id(3)["leased_by"] is None # текущий всё равно освобождён
# ── _rotate_avito_proxy — pool-first, changeip fallback ────────────────────
@pytest.mark.asyncio
async def test_rotate_avito_proxy_uses_pool_when_flag_on():
db = FakeSession(
[
_proxy(1, fails=0), # свободный здоровый — цель ротации
_proxy(3, fails=0, leased_by=999), # CURRENT
]
)
current_lease = proxy_pool.ProxyLease(
id=3, url="http://u:p@h3:8080", kind="http", rotate_url=None
)
session = SimpleNamespace(proxies=None)
with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True)):
with patch("app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock()) as changeip_mock:
rotated, new_lease = await _rotate_avito_proxy(
db, # type: ignore[arg-type]
session, # type: ignore[arg-type]
current_lease,
reason="test",
rotations_done=0,
)
assert rotated is True
assert new_lease is not None and new_lease.id == 1
changeip_mock.assert_not_awaited() # пул сработал — changeip не нужен
@pytest.mark.asyncio
async def test_rotate_avito_proxy_falls_back_to_changeip_when_flag_off():
session = SimpleNamespace(proxies=None)
with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=False)):
with patch(
"app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock(return_value=True)
) as changeip_mock:
rotated, new_lease = await _rotate_avito_proxy(
None, # type: ignore[arg-type]
session, # type: ignore[arg-type]
None,
reason="test",
rotations_done=0,
)
assert rotated is True
assert new_lease is None
changeip_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_rotate_avito_proxy_falls_back_to_changeip_when_pool_exhausted():
"""Пул on, но acquire() пуст (например все забанены) — падаем на changeip, не молчим."""
db = FakeSession([_proxy(1, fails=MAX_CONSECUTIVE_FAILS)]) # единственный — забанен
session = SimpleNamespace(proxies=None)
with patch("app.services.scrape_pipeline.settings", _mock_settings(use_proxy_pool_curl=True)):
with patch(
"app.services.scrape_pipeline._rotate_proxy_ip", AsyncMock(return_value=True)
) as changeip_mock:
rotated, new_lease = await _rotate_avito_proxy(
db, # type: ignore[arg-type]
session, # type: ignore[arg-type]
None,
reason="test",
rotations_done=0,
)
assert rotated is True
assert new_lease is None
changeip_mock.assert_awaited_once() # pool exhausted → fallback path taken
# ── _release_pool_lease ──────────────────────────────────────────────────────
def test_release_pool_lease_noop_when_none():
_release_pool_lease(None, None, ok=True) # type: ignore[arg-type] # must not raise
def test_release_pool_lease_marks_health_and_releases():
db = FakeSession([_proxy(1, leased_by=100)])
lease = proxy_pool.ProxyLease(id=1, url="http://u:p@h1:8080", kind="http", rotate_url=None)
_release_pool_lease(db, lease, ok=True) # type: ignore[arg-type]
assert db._by_id(1)["leased_by"] is None
assert db._by_id(1)["consecutive_fails"] == 0