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 pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
@@ -148,11 +149,18 @@ async def register(
|
||||
limit_bytes=104857600, # 100 MB default (STORE-01)
|
||||
used_bytes=0,
|
||||
)
|
||||
try:
|
||||
session.add(new_user)
|
||||
await session.flush() # persist User before Quota FK
|
||||
session.add(quota)
|
||||
await session.commit()
|
||||
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 {
|
||||
"id": str(new_user.id),
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"""Locust load test for DocuVault — D-04, D-05, D-06.
|
||||
|
||||
Strategy: self-bootstrapping (Option A) — on_start registers the load-test
|
||||
user (409 ignored if already exists), then logs in to get an access token.
|
||||
Strategy: pre-authenticated users (Option B) — a test_start listener creates
|
||||
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):
|
||||
locust --headless --users 50 --spawn-rate 10 --run-time 5m \\
|
||||
locust --headless --users 50 --spawn-rate 10 --run-time 12m \\
|
||||
--host http://localhost:8000 \\
|
||||
--csv backend/load_tests/results \\
|
||||
-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:
|
||||
pip install -r backend/requirements-dev.txt
|
||||
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
|
||||
1 — p95 > 200 ms, OR p99 > 500 ms, OR fail_ratio > 1%
|
||||
|
||||
Cleanup the load-test user:
|
||||
docker compose exec postgres psql -U docuvault -c \\
|
||||
"DELETE FROM users WHERE email='loadtest@example.com';"
|
||||
Cleanup load-test users:
|
||||
docker compose exec postgres psql -U postgres -d docuvault -c \\
|
||||
"DELETE FROM users WHERE email LIKE 'loadtest%@example.com';"
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import requests as _req
|
||||
from locust import HttpUser, between, events, task
|
||||
|
||||
TEST_EMAIL = os.environ.get("LOAD_TEST_EMAIL", "loadtest@example.com")
|
||||
TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#")
|
||||
TEST_HANDLE = "loadtestuser"
|
||||
_BASE_URL = os.environ.get("LOAD_TEST_HOST", "http://localhost:8000")
|
||||
_TEST_PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "Loadtest123!@#")
|
||||
_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"
|
||||
|
||||
|
||||
@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):
|
||||
"""Simulated DocuVault end-user exercising all authenticated endpoints.
|
||||
|
||||
@@ -48,18 +115,14 @@ class DocuVaultUser(HttpUser):
|
||||
access_token: str = ""
|
||||
|
||||
def on_start(self) -> None:
|
||||
self.client.post(
|
||||
"/api/auth/register",
|
||||
json={"handle": TEST_HANDLE, "email": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/auth/login",
|
||||
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", "")
|
||||
global _TOKEN_IDX
|
||||
with _TOKEN_LOCK:
|
||||
idx = _TOKEN_IDX
|
||||
_TOKEN_IDX += 1
|
||||
if idx < len(_TOKEN_POOL):
|
||||
self.access_token = _TOKEN_POOL[idx]
|
||||
else:
|
||||
print(f"[worker] No token available for slot {idx} — stopping runner")
|
||||
self.environment.runner.quit()
|
||||
|
||||
def _auth_headers(self) -> dict:
|
||||
@@ -88,8 +151,6 @@ class DocuVaultUser(HttpUser):
|
||||
|
||||
@task(2)
|
||||
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(
|
||||
"/api/documents/upload",
|
||||
headers=self._auth_headers(),
|
||||
@@ -115,7 +176,12 @@ def check_sla(environment, **kwargs) -> None:
|
||||
p99 = stats.get_response_time_percentile(0.99)
|
||||
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%")
|
||||
environment.process_exit_code = 1
|
||||
elif p95 > 200:
|
||||
@@ -125,4 +191,4 @@ def check_sla(environment, **kwargs) -> None:
|
||||
print(f"SLA FAIL: p99={p99:.0f}ms > 500ms")
|
||||
environment.process_exit_code = 1
|
||||
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