fix(auth): catch IntegrityError on register race → 409 + locustfile pre-auth rewrite
Two fixes from manual SLA validation (06-LOCUST-01): 1. backend/api/auth.py: concurrent registrations with same email/handle caused UniqueViolation to bubble as 500. Now wraps session.commit() in try/except IntegrityError → 409 Conflict. Regression covered by existing auth API tests. 2. backend/load_tests/locustfile.py: rewrote from single-shared-account to pre-authenticated unique accounts. New @events.test_start listener creates 50 accounts in 9-user batches (65 s pause between batches to stay under 10 req/min IP rate limit). Added min-request guard to SLA check so a run-time-consumed-by-setup result never silently "passes". Run command updated to --run-time 12m. Known open issue: _account_key in services/rate_limiting.py falls back to IP when request.state.current_user is not yet set (rate limiter key_func runs before handler body). Fix: extract sub from JWT directly. See next session. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3f0e2ab44c
commit
0fa23f5211
@@ -26,6 +26,7 @@ from typing import Literal, Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
@@ -148,11 +149,18 @@ async def register(
|
|||||||
limit_bytes=104857600, # 100 MB default (STORE-01)
|
limit_bytes=104857600, # 100 MB default (STORE-01)
|
||||||
used_bytes=0,
|
used_bytes=0,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
session.add(new_user)
|
session.add(new_user)
|
||||||
await session.flush() # persist User before Quota FK
|
await session.flush() # persist User before Quota FK
|
||||||
session.add(quota)
|
session.add(quota)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(new_user)
|
await session.refresh(new_user)
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Email or handle already in use",
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(new_user.id),
|
"id": str(new_user.id),
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"""Locust load test for DocuVault — D-04, D-05, D-06.
|
"""Locust load test for DocuVault — D-04, D-05, D-06.
|
||||||
|
|
||||||
Strategy: self-bootstrapping (Option A) — on_start registers the load-test
|
Strategy: pre-authenticated users (Option B) — a test_start listener creates
|
||||||
user (409 ignored if already exists), then logs in to get an access token.
|
and authenticates all 50 virtual users before spawning begins. This avoids
|
||||||
|
the IP-based auth rate limiter (10 req/min) hitting during the ramp-up phase,
|
||||||
|
while still exercising per-user isolated document endpoints under real load.
|
||||||
|
|
||||||
Run (headless, matches Phase 6 SLA gate):
|
Run (headless, matches Phase 6 SLA gate):
|
||||||
locust --headless --users 50 --spawn-rate 10 --run-time 5m \\
|
locust --headless --users 50 --spawn-rate 10 --run-time 12m \\
|
||||||
--host http://localhost:8000 \\
|
--host http://localhost:8000 \\
|
||||||
--csv backend/load_tests/results \\
|
--csv backend/load_tests/results \\
|
||||||
-f backend/load_tests/locustfile.py
|
-f backend/load_tests/locustfile.py
|
||||||
|
|
||||||
|
NOTE: --run-time must be 12m+ because Locust counts from process start.
|
||||||
|
Setup takes ~6 min (6 batches × 65 s to respect 10 req/min auth rate limit).
|
||||||
|
On re-runs users already exist (409 on register) — still need login pacing.
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
pip install -r backend/requirements-dev.txt
|
pip install -r backend/requirements-dev.txt
|
||||||
docker compose up # backend, postgres, minio must be running
|
docker compose up # backend, postgres, minio must be running
|
||||||
@@ -17,23 +23,84 @@ Exit codes (enforced by check_sla listener):
|
|||||||
0 — all SLA thresholds met
|
0 — all SLA thresholds met
|
||||||
1 — p95 > 200 ms, OR p99 > 500 ms, OR fail_ratio > 1%
|
1 — p95 > 200 ms, OR p99 > 500 ms, OR fail_ratio > 1%
|
||||||
|
|
||||||
Cleanup the load-test user:
|
Cleanup load-test users:
|
||||||
docker compose exec postgres psql -U docuvault -c \\
|
docker compose exec postgres psql -U postgres -d docuvault -c \\
|
||||||
"DELETE FROM users WHERE email='loadtest@example.com';"
|
"DELETE FROM users WHERE email LIKE 'loadtest%@example.com';"
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
|
import requests as _req
|
||||||
from locust import HttpUser, between, events, task
|
from locust import HttpUser, between, events, task
|
||||||
|
|
||||||
TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com")
|
_BASE_URL = os.environ.get("LOAD_TEST_HOST", "http://localhost:8000")
|
||||||
TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#")
|
_TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#")
|
||||||
TEST_HANDLE = "loadtestuser"
|
_NUM_USERS = int(os.environ.get("LOAD_TEST_USERS", "50"))
|
||||||
|
|
||||||
|
# Populated by the test_start listener; each slot is an access_token string.
|
||||||
|
_TOKEN_POOL: list[str] = []
|
||||||
|
_TOKEN_LOCK = threading.Lock()
|
||||||
|
_TOKEN_IDX = 0
|
||||||
|
|
||||||
# Minimal synthetic PDF — valid enough for the upload parser
|
|
||||||
_FAKE_PDF = b"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\nxref\n0 2\ntrailer<</Size 2>>\n%%EOF"
|
_FAKE_PDF = b"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\nxref\n0 2\ntrailer<</Size 2>>\n%%EOF"
|
||||||
|
|
||||||
|
|
||||||
|
@events.test_start.add_listener
|
||||||
|
def pre_authenticate(environment, **kwargs) -> None:
|
||||||
|
"""Create and authenticate all virtual users before spawning begins.
|
||||||
|
|
||||||
|
Rate limit on /login is 10 req/min per IP (fixed-window). We use a
|
||||||
|
batch of 9 logins then a 65-second pause so each batch fits safely inside
|
||||||
|
one window before the counter resets. Setup runs before the test timer,
|
||||||
|
so this does not count against the 5-minute SLA window.
|
||||||
|
|
||||||
|
50 users ÷ 9 per batch = 6 batches × 65 s ≈ 6 minutes setup.
|
||||||
|
"""
|
||||||
|
print(f"[setup] Pre-creating {_NUM_USERS} load-test accounts …")
|
||||||
|
session = _req.Session()
|
||||||
|
batch_size = 9 # /register and /login each have 10 req/min; 9 per window is safe
|
||||||
|
|
||||||
|
for batch_start in range(0, _NUM_USERS, batch_size):
|
||||||
|
batch_end = min(batch_start + batch_size, _NUM_USERS)
|
||||||
|
batch_num = batch_start // batch_size + 1
|
||||||
|
total_batches = (_NUM_USERS + batch_size - 1) // batch_size
|
||||||
|
|
||||||
|
for i in range(batch_start, batch_end):
|
||||||
|
email = f"loadtest{i}@example.com"
|
||||||
|
handle = f"loadtest{i}"
|
||||||
|
|
||||||
|
# Register — 409 means already exists, both are fine
|
||||||
|
session.post(
|
||||||
|
f"{_BASE_URL}/api/auth/register",
|
||||||
|
json={"handle": handle, "email": email, "password": _TEST_PASSWORD},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login
|
||||||
|
r = session.post(
|
||||||
|
f"{_BASE_URL}/api/auth/login",
|
||||||
|
json={"email": email, "password": _TEST_PASSWORD},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
_TOKEN_POOL.append(r.json().get("access_token", ""))
|
||||||
|
else:
|
||||||
|
print(f"[setup] WARNING: login failed for loadtest{i} (status={r.status_code})")
|
||||||
|
_TOKEN_POOL.append("") # placeholder keeps slot indices aligned
|
||||||
|
|
||||||
|
if batch_end < _NUM_USERS:
|
||||||
|
print(
|
||||||
|
f"[setup] Batch {batch_num}/{total_batches} done ({batch_end}/{_NUM_USERS}). "
|
||||||
|
f"Waiting 65 s for rate-limit window reset …"
|
||||||
|
)
|
||||||
|
time.sleep(65)
|
||||||
|
|
||||||
|
ready = sum(1 for t in _TOKEN_POOL if t)
|
||||||
|
print(f"[setup] {ready}/{_NUM_USERS} accounts authenticated and ready.")
|
||||||
|
|
||||||
|
|
||||||
class DocuVaultUser(HttpUser):
|
class DocuVaultUser(HttpUser):
|
||||||
"""Simulated DocuVault end-user exercising all authenticated endpoints.
|
"""Simulated DocuVault end-user exercising all authenticated endpoints.
|
||||||
|
|
||||||
@@ -48,18 +115,14 @@ class DocuVaultUser(HttpUser):
|
|||||||
access_token: str = ""
|
access_token: str = ""
|
||||||
|
|
||||||
def on_start(self) -> None:
|
def on_start(self) -> None:
|
||||||
self.client.post(
|
global _TOKEN_IDX
|
||||||
"/api/auth/register",
|
with _TOKEN_LOCK:
|
||||||
json={"handle": TEST_HANDLE, "email": TEST_EMAIL, "password": TEST_PASSWORD},
|
idx = _TOKEN_IDX
|
||||||
)
|
_TOKEN_IDX += 1
|
||||||
resp = self.client.post(
|
if idx < len(_TOKEN_POOL):
|
||||||
"/api/auth/login",
|
self.access_token = _TOKEN_POOL[idx]
|
||||||
json={"email": TEST_EMAIL, "password": TEST_PASSWORD},
|
|
||||||
name="POST /api/auth/login",
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
self.access_token = resp.json().get("access_token", "")
|
|
||||||
else:
|
else:
|
||||||
|
print(f"[worker] No token available for slot {idx} — stopping runner")
|
||||||
self.environment.runner.quit()
|
self.environment.runner.quit()
|
||||||
|
|
||||||
def _auth_headers(self) -> dict:
|
def _auth_headers(self) -> dict:
|
||||||
@@ -88,8 +151,6 @@ class DocuVaultUser(HttpUser):
|
|||||||
|
|
||||||
@task(2)
|
@task(2)
|
||||||
def upload_document(self) -> None:
|
def upload_document(self) -> None:
|
||||||
# Direct single-step upload to /api/documents/upload (confirmed against documents.py:
|
|
||||||
# accepts UploadFile `file` + Form `target_backend` — simpler than the presigned flow).
|
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/api/documents/upload",
|
"/api/documents/upload",
|
||||||
headers=self._auth_headers(),
|
headers=self._auth_headers(),
|
||||||
@@ -115,7 +176,12 @@ def check_sla(environment, **kwargs) -> None:
|
|||||||
p99 = stats.get_response_time_percentile(0.99)
|
p99 = stats.get_response_time_percentile(0.99)
|
||||||
fail_ratio = stats.fail_ratio
|
fail_ratio = stats.fail_ratio
|
||||||
|
|
||||||
if fail_ratio > 0.01:
|
total_reqs = stats.num_requests
|
||||||
|
if total_reqs < 500:
|
||||||
|
# Setup consumed the entire run-time window; re-run with --run-time 12m
|
||||||
|
print(f"SLA FAIL: only {total_reqs} requests — setup consumed run-time. Use --run-time 12m")
|
||||||
|
environment.process_exit_code = 1
|
||||||
|
elif fail_ratio > 0.01:
|
||||||
print(f"SLA FAIL: fail_ratio={fail_ratio:.2%} > 1%")
|
print(f"SLA FAIL: fail_ratio={fail_ratio:.2%} > 1%")
|
||||||
environment.process_exit_code = 1
|
environment.process_exit_code = 1
|
||||||
elif p95 > 200:
|
elif p95 > 200:
|
||||||
@@ -125,4 +191,4 @@ def check_sla(environment, **kwargs) -> None:
|
|||||||
print(f"SLA FAIL: p99={p99:.0f}ms > 500ms")
|
print(f"SLA FAIL: p99={p99:.0f}ms > 500ms")
|
||||||
environment.process_exit_code = 1
|
environment.process_exit_code = 1
|
||||||
else:
|
else:
|
||||||
print(f"SLA PASS: p95={p95:.0f}ms p99={p99:.0f}ms fail_ratio={fail_ratio:.2%}")
|
print(f"SLA PASS: p95={p95:.0f}ms p99={p99:.0f}ms fail_ratio={fail_ratio:.2%} reqs={total_reqs}")
|
||||||
|
|||||||
Reference in New Issue
Block a user