All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m41s
Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота — счётная и помесячная: параллелизм /estimate не ограничивал никто. Оценка 0.8–2.4с держит соединение общего пула SQLAlchemy (5+10) и внешние тиры — пила одновременных оценок выедала пул и тормозила весь /api/v1/*. Семафор по образцу public/mera.py::_suggest_slots: acquire после дешёвых отказов (рейт-лимит, квота) с ожиданием 5с ≈ две длительности оценки, timeout → 429 с Retry-After; release в finally сразу после дорогой части. 4+4 слота (estimate+suggest) = 8 удерживаемых соединений из 15 пула. Семафор в памяти процесса — при переходе на несколько воркеров (#3083) лимит умножится на их число; задачи согласовывать (о чём комментарий на месте). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
144 lines
6.4 KiB
Python
144 lines
6.4 KiB
Python
"""#3082: потолок одновременных оценок на POST /estimate.
|
||
|
||
Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота —
|
||
счётная и помесячная: параллелизм не ограничивал никто, и пила одновременных
|
||
оценок (0.8–2.4с каждая, соединение общего пула + внешние тиры) выедала пул и
|
||
тормозила весь /api/v1/*. Фикс — asyncio.Semaphore по образцу public/mera.py.
|
||
|
||
Красный на origin/main по ЗНАЧЕНИЮ: без семафора все 5 конкурентных запросов
|
||
проходят (0×429), с ним пятый получает быстрый 429 «занят». Никаких обращений
|
||
к новым именам модуля напрямую — monkeypatch констант с raising=False, чтобы
|
||
на main тест падал ассертом о поведении, а не AttributeError (см.
|
||
red-must-mean-wrong-value).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import asyncio
|
||
from datetime import UTC, datetime, timedelta
|
||
from unittest.mock import patch
|
||
from uuid import uuid4
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
from app.core.db import get_db
|
||
from app.core.ratelimit import SlidingWindowLimiter
|
||
from app.schemas.trade_in import AggregatedEstimate
|
||
|
||
_CONCURRENCY = 4 # прод-значение _ESTIMATE_CONCURRENCY; здесь литералом (см. док-стринг)
|
||
|
||
|
||
def _canned_estimate() -> AggregatedEstimate:
|
||
return AggregatedEstimate(
|
||
estimate_id=uuid4(),
|
||
median_price_rub=5_000_000,
|
||
range_low_rub=4_500_000,
|
||
range_high_rub=5_500_000,
|
||
median_price_per_m2=100_000,
|
||
confidence="medium",
|
||
n_analogs=8,
|
||
period_months=24,
|
||
analogs=[],
|
||
actual_deals=[],
|
||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def app() -> FastAPI:
|
||
application = FastAPI()
|
||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||
|
||
def _override_db():
|
||
yield None
|
||
|
||
application.dependency_overrides[get_db] = _override_db
|
||
return application
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _wide_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Рейт-лимит не должен мешать тесту параллелизма: 100/60с."""
|
||
monkeypatch.setattr(
|
||
trade_in_module, "_estimate_limiter", SlidingWindowLimiter(limit=100, window_s=60.0)
|
||
)
|
||
|
||
|
||
async def test_fifth_concurrent_estimate_gets_fast_429(
|
||
app: FastAPI, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""4 оценки висят в работе → 5-я не ждёт в очереди, а быстро получает 429
|
||
с текстом про занятость и Retry-After; после освобождения слотов те же 4
|
||
завершаются 200 и следующий запрос снова проходит (release в finally)."""
|
||
# raising=False: на main этих имён нет — тест должен упасть ассертом ниже,
|
||
# а не AttributeError здесь.
|
||
monkeypatch.setattr(trade_in_module, "_ESTIMATE_SLOT_WAIT_S", 0.1, raising=False)
|
||
monkeypatch.setattr(
|
||
trade_in_module, "_estimate_slots", asyncio.Semaphore(_CONCURRENCY), raising=False
|
||
)
|
||
|
||
gate = asyncio.Event()
|
||
|
||
async def _slow_estimate(*args, **kwargs) -> AggregatedEstimate:
|
||
await gate.wait()
|
||
return _canned_estimate()
|
||
|
||
payload = {"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2}
|
||
with (
|
||
patch("app.services.account_quota.check_and_raise"),
|
||
patch("app.services.account_quota.increment", return_value=True),
|
||
patch("app.services.estimator.estimate_quality", new=_slow_estimate),
|
||
):
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||
holders = [
|
||
asyncio.create_task(client.post("/api/v1/trade-in/estimate", json=payload))
|
||
for _ in range(_CONCURRENCY)
|
||
]
|
||
# Дать держателям дойти до acquire и занять все слоты.
|
||
await asyncio.sleep(0.05)
|
||
|
||
fifth = await client.post("/api/v1/trade-in/estimate", json=payload)
|
||
assert fifth.status_code == 429, (
|
||
f"5-й конкурентный запрос прошёл ({fifth.status_code}) — "
|
||
"потолка одновременности нет"
|
||
)
|
||
assert "занят" in fifth.json()["detail"]
|
||
assert "Retry-After" in fifth.headers
|
||
|
||
gate.set()
|
||
done = await asyncio.gather(*holders)
|
||
assert [r.status_code for r in done] == [200] * _CONCURRENCY
|
||
|
||
# Слоты вернулись (release в finally) — новый запрос проходит.
|
||
gate.set()
|
||
again = await client.post("/api/v1/trade-in/estimate", json=payload)
|
||
assert again.status_code == 200
|
||
|
||
|
||
async def test_within_limit_behaviour_unchanged(
|
||
app: FastAPI, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""В пределах лимита семафор прозрачен: одиночный запрос — 200, как раньше."""
|
||
monkeypatch.setattr(trade_in_module, "_ESTIMATE_SLOT_WAIT_S", 0.1, raising=False)
|
||
|
||
async def _fast_estimate(*args, **kwargs) -> AggregatedEstimate:
|
||
return _canned_estimate()
|
||
|
||
payload = {"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2}
|
||
with (
|
||
patch("app.services.account_quota.check_and_raise"),
|
||
patch("app.services.account_quota.increment", return_value=True),
|
||
patch("app.services.estimator.estimate_quality", new=_fast_estimate),
|
||
):
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||
resp = await client.post("/api/v1/trade-in/estimate", json=payload)
|
||
assert resp.status_code == 200
|