Compare commits

...

2 commits

Author SHA1 Message Date
29ea46193e test(tradein): mock has_running_run в sweep-endpoint тестах после single-run guard
test_start_endpoint_ok / test_sweep_endpoint_passes_enrich_imv POST'ят на
/scrape/avito-city-sweep с mock-db (has_running_run → truthy) → новый guard
давал 409 вместо 200. Мокаем app.api.v1.admin.has_running_run→False (патчим
где используется). + ruff autofix import-sort, убран unused mock_create.
2026-05-31 12:02:05 +03:00
d9d6caa044 fix(tradein): single-run guard на avito-city-sweep admin-endpoint
Параллельные avito-sweep'ы делят один прокси-IP → удвоенный rate → Avito бан.
Incident 2026-05-31: runs #26 (08:31-08:42) и #27 (08:38-08:43) шли
одновременно → оба banned (скомпрометировало delay-эксперимент). Scheduler
уже защищён (has_running_run + advisory lock в _claim_run), но ручной
admin-endpoint POST /scrape/avito-city-sweep guard'а не имел.

- admin.py: has_running_run('avito_city_sweep') → HTTPException 409 перед create_run.
- test: monkeypatch has_running_run→True → 409.
2026-05-31 11:53:51 +03:00
4 changed files with 44 additions and 7 deletions

View file

@ -22,6 +22,7 @@ from app.schemas.trade_in import ScheduleConfig, ScheduleConfigUpdate
from app.services import cian_session as cian_session_svc
from app.services import scrape_runs as runs_mod
from app.services.geocoder import geocode
from app.services.scheduler import has_running_run
from app.services.scrape_pipeline import (
run_avito_city_sweep,
run_cian_city_sweep,
@ -634,7 +635,22 @@ async def start_avito_city_sweep(
Coop cancel: POST /scrape/avito-city-sweep/{run_id}/cancel помечает run,
pipeline проверяет статус каждый anchor.
Single-run guard: второй sweep запустить нельзя, пока первый running
параллельные раны делят один прокси-IP удвоенный rate Avito бан
(incident 2026-05-31: runs #26+#27 шли одновременно → оба banned).
Scheduler уже защищён (has_running_run + advisory lock); этот guard
закрывает ручной admin-триггер.
"""
if has_running_run(db, "avito_city_sweep"):
raise HTTPException(
status_code=409,
detail=(
"avito city-sweep уже выполняется — одновременно допустим только один "
"(параллельные раны делят прокси-IP → Avito бан). Дождитесь завершения "
"или отмените текущий через POST /scrape/avito-city-sweep/{run_id}/cancel."
),
)
run_id = runs_mod.create_run(db, source="avito_city_sweep", params=payload.model_dump())
async def _sweep_task() -> None:

View file

@ -104,3 +104,21 @@ def test_get_imv_benchmark_unavailable(app):
# endpoint вернёт 404 потому что fallback тоже не находит address → нужен estimate row
# Этот тест проверяет что эндпоинт mounted; 404 ОК
assert r.status_code in (200, 404)
def test_avito_city_sweep_single_run_guard(app, monkeypatch):
"""#single-run: второй avito sweep при уже running → 409.
Параллельные sweep'ы делят один прокси-IP → удвоенный rate → Avito бан
(incident 2026-05-31: runs #26+#27 одновременно → оба banned).
"""
from app.api.v1 import admin as admin_module
monkeypatch.setattr(admin_module, "has_running_run", lambda db, source: True)
client = TestClient(app)
r = client.post(
"/api/v1/admin/scrape/avito-city-sweep",
json={"pages_per_anchor": 1, "request_delay_sec": 5.0},
)
assert r.status_code == 409
assert "уже выполняется" in r.json()["detail"]

View file

@ -9,11 +9,11 @@ import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── EKB_ANCHORS ────────────────────────────────────────────────────────────
@ -53,9 +53,10 @@ def test_city_sweep_counters_to_dict() -> None:
def test_city_sweep_counters_to_dict_all_keys() -> None:
from app.services.scrape_pipeline import CitySweepCounters
from dataclasses import fields
from app.services.scrape_pipeline import CitySweepCounters
c = CitySweepCounters()
d = c.to_dict()
expected_keys = {f.name for f in fields(c)}
@ -174,7 +175,8 @@ def test_start_endpoint_validates_request_delay_too_low(app_with_admin) -> None:
def test_start_endpoint_ok(app_with_admin) -> None:
"""Valid request → 200, run_id в ответе."""
with (
patch("app.services.scrape_runs.create_run", return_value=7) as mock_create,
patch("app.api.v1.admin.has_running_run", return_value=False),
patch("app.services.scrape_runs.create_run", return_value=7),
patch("app.services.scrape_pipeline.run_avito_city_sweep", new_callable=AsyncMock),
):
r = app_with_admin.post(
@ -209,7 +211,7 @@ def test_cancel_endpoint_not_running(app_with_admin) -> None:
def test_list_runs_endpoint(app_with_admin) -> None:
"""List runs endpoint возвращает список."""
from datetime import datetime, timezone
from datetime import datetime
fake_rows = [
{
@ -219,9 +221,9 @@ def test_list_runs_endpoint(app_with_admin) -> None:
"params": {"pages_per_anchor": 3},
"counters": {"lots_fetched": 300},
"error": None,
"started_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
"finished_at": datetime(2025, 1, 1, 1, tzinfo=timezone.utc),
"heartbeat_at": datetime(2025, 1, 1, 1, tzinfo=timezone.utc),
"started_at": datetime(2025, 1, 1, tzinfo=UTC),
"finished_at": datetime(2025, 1, 1, 1, tzinfo=UTC),
"heartbeat_at": datetime(2025, 1, 1, 1, tzinfo=UTC),
}
]
with patch("app.services.scrape_runs.list_recent", return_value=fake_rows):

View file

@ -443,6 +443,7 @@ def test_city_sweep_start_request_enrich_imv_false() -> None:
def test_sweep_endpoint_passes_enrich_imv(app_with_admin) -> None:
"""POST /scrape/avito-city-sweep с enrich_imv=False → sweep вызывается без IMV."""
with (
patch("app.api.v1.admin.has_running_run", return_value=False),
patch("app.services.scrape_runs.create_run", return_value=42),
patch(
"app.services.scrape_pipeline.run_avito_city_sweep",