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:
curo1305
2026-06-05 12:25:04 +02:00
co-authored by Claude Sonnet 4.6
parent 3f0e2ab44c
commit 0fa23f5211
2 changed files with 104 additions and 30 deletions
+8
View File
@@ -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),
+91 -25
View File
@@ -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}")