feat(api): add per-user health-aware upload rate limiting
Introduces a Redis-backed sliding-window rate limiter for upload endpoints (/api/ui-upload, /api/process-url) that: - Enforces per-user limits (default: 20 uploads / 60 s) - Dynamically reduces limits under system stress (queue depth, CPU load) - Returns 429 with Retry-After header when exceeded - Fails open when Redis is unavailable - Works with the existing client-side adaptive back-off Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,10 @@ COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, S
|
||||
# DB_POOL_TIMEOUT=30 # Seconds to wait for a pool connection (default: 30)
|
||||
# DB_POOL_RECYCLE=1800 # Recycle connections after N seconds (default: 1800)
|
||||
|
||||
# **Per-User Upload Rate Limiting** (health-aware, Redis-backed)
|
||||
# UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window (default: 20)
|
||||
# UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds (default: 60)
|
||||
|
||||
# **System Reset / Factory Reset**
|
||||
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
|
||||
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
|
||||
|
||||
+7
-1
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
from app.models import FileProcessingStep, FileRecord, ProcessingLog
|
||||
from app.tasks.convert_to_pdf import convert_to_pdf
|
||||
from app.tasks.process_document import process_document
|
||||
@@ -1280,7 +1281,12 @@ def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: s
|
||||
|
||||
@router.post("/ui-upload")
|
||||
@require_login
|
||||
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
|
||||
async def ui_upload(
|
||||
request: Request,
|
||||
db: DbSession,
|
||||
file: UploadFile = File(...),
|
||||
_rate_ok: None = Depends(require_upload_rate_limit),
|
||||
):
|
||||
"""Endpoint to accept a user-uploaded file and enqueue it for processing."""
|
||||
workdir = settings.workdir
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, HttpUrl, field_validator
|
||||
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
from app.tasks.process_document import process_document
|
||||
from app.utils.allowed_types import ALLOWED_MIME_TYPES
|
||||
from app.utils.filename_utils import sanitize_filename
|
||||
@@ -107,7 +108,11 @@ def validate_file_type(content_type: str, filename: str) -> bool:
|
||||
|
||||
@router.post("/process-url")
|
||||
@require_login
|
||||
async def process_url(request: Request, url_request: URLUploadRequest):
|
||||
async def process_url(
|
||||
request: Request,
|
||||
url_request: URLUploadRequest,
|
||||
_rate_ok: None = Depends(require_upload_rate_limit),
|
||||
):
|
||||
"""
|
||||
Download a file from a URL and enqueue it for processing.
|
||||
|
||||
|
||||
@@ -1143,6 +1143,20 @@ class Settings(BaseSettings):
|
||||
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
|
||||
)
|
||||
|
||||
# Per-user upload rate limiting (health-aware, Redis-backed sliding window)
|
||||
upload_rate_limit_per_user: int = Field(
|
||||
default=20,
|
||||
description=(
|
||||
"Maximum number of file uploads allowed per user within the sliding window. "
|
||||
"The effective limit may be reduced dynamically when the system is under heavy load "
|
||||
"(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting."
|
||||
),
|
||||
)
|
||||
upload_rate_limit_window: int = Field(
|
||||
default=60,
|
||||
description="Sliding window size in seconds for per-user upload rate limiting (default: 60).",
|
||||
)
|
||||
|
||||
# CORS Configuration (see SECURITY_AUDIT.md – Infrastructure Security section)
|
||||
# Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.)
|
||||
# that already adds CORS headers. Enable only if deploying without a reverse proxy or if
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Per-user, health-aware upload rate limiter for DocuElevate.
|
||||
|
||||
This module provides a FastAPI dependency that enforces per-user upload rate
|
||||
limits using a Redis-backed sliding window counter. The effective limit is
|
||||
dynamically reduced when the system is under heavy load (high Celery queue
|
||||
depth or elevated CPU load average), ensuring the server remains responsive
|
||||
to all users even during bulk-upload scenarios.
|
||||
|
||||
Usage in an endpoint::
|
||||
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
@router.post("/ui-upload")
|
||||
@require_login
|
||||
async def ui_upload(
|
||||
request: Request,
|
||||
_rate_ok: None = Depends(require_upload_rate_limit),
|
||||
...
|
||||
):
|
||||
...
|
||||
|
||||
See ``docs/ConfigurationGuide.md`` for the configuration options
|
||||
(``UPLOAD_RATE_LIMIT_PER_USER``, ``UPLOAD_RATE_LIMIT_WINDOW``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import redis
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis key prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
_KEY_PREFIX = "docuelevate:upload_rate"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health-check queue names (Celery defaults used by DocuElevate)
|
||||
# ---------------------------------------------------------------------------
|
||||
_CELERY_QUEUES = ("document_processor", "default", "celery")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton Redis client (lazy-initialised; fail-open when unavailable)
|
||||
# ---------------------------------------------------------------------------
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
def _get_redis() -> redis.Redis | None:
|
||||
"""Return a shared Redis client, or *None* when Redis is unavailable."""
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
_redis_client = redis.Redis.from_url(
|
||||
settings.redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
)
|
||||
# Quick connectivity check – raises on failure.
|
||||
_redis_client.ping()
|
||||
return _redis_client
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Redis unavailable for upload rate limiter – falling back to allow-all")
|
||||
_redis_client = None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health metrics helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_queue_depth(r: redis.Redis) -> int:
|
||||
"""Return the total number of pending tasks across all Celery queues."""
|
||||
total = 0
|
||||
for queue_name in _CELERY_QUEUES:
|
||||
try:
|
||||
total += r.llen(queue_name)
|
||||
except Exception: # noqa: BLE001, S110
|
||||
logger.debug("Could not read queue length for '%s'", queue_name)
|
||||
return total
|
||||
|
||||
|
||||
def _get_cpu_load_ratio() -> float:
|
||||
"""Return the 1-minute load average divided by the number of CPU cores.
|
||||
|
||||
Returns ``0.0`` on platforms that do not support :func:`os.getloadavg`
|
||||
(e.g. Windows) so that the limiter never penalises on those systems.
|
||||
"""
|
||||
try:
|
||||
load_1m = os.getloadavg()[0]
|
||||
cpu_count = os.cpu_count() or 1
|
||||
return load_1m / cpu_count
|
||||
except (OSError, AttributeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def compute_effective_limit(
|
||||
base_limit: int,
|
||||
queue_depth: int = 0,
|
||||
cpu_load_ratio: float = 0.0,
|
||||
) -> tuple[int, float, str]:
|
||||
"""Compute the effective upload rate limit based on system health.
|
||||
|
||||
The function applies a *reduction factor* (``0.0 < factor ≤ 1.0``) to the
|
||||
configured base limit. Both queue depth and CPU load contribute
|
||||
independently; the lowest factor wins.
|
||||
|
||||
Args:
|
||||
base_limit: The configured maximum uploads per window.
|
||||
queue_depth: Total pending tasks in Celery queues.
|
||||
cpu_load_ratio: 1-minute load average divided by CPU count.
|
||||
|
||||
Returns:
|
||||
A 3-tuple of ``(effective_limit, factor, reason)`` where *reason*
|
||||
is a human-readable tag for logging.
|
||||
"""
|
||||
factor = 1.0
|
||||
reason = "normal"
|
||||
|
||||
# --- Queue-depth thresholds ---
|
||||
if queue_depth > 200:
|
||||
factor, reason = min(factor, 0.10), f"critical_queue({queue_depth})"
|
||||
elif queue_depth > 100:
|
||||
factor, reason = min(factor, 0.25), f"high_queue({queue_depth})"
|
||||
elif queue_depth > 50:
|
||||
factor, reason = min(factor, 0.50), f"moderate_queue({queue_depth})"
|
||||
|
||||
# --- CPU-load thresholds ---
|
||||
if cpu_load_ratio > 3.0:
|
||||
new_factor = 0.10
|
||||
if new_factor < factor:
|
||||
factor, reason = new_factor, f"critical_cpu({cpu_load_ratio:.1f})"
|
||||
elif cpu_load_ratio > 2.0:
|
||||
new_factor = 0.25
|
||||
if new_factor < factor:
|
||||
factor, reason = new_factor, f"high_cpu({cpu_load_ratio:.1f})"
|
||||
elif cpu_load_ratio > 1.5:
|
||||
new_factor = 0.50
|
||||
if new_factor < factor:
|
||||
factor, reason = new_factor, f"moderate_cpu({cpu_load_ratio:.1f})"
|
||||
|
||||
effective = max(1, int(base_limit * factor))
|
||||
return effective, factor, reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core sliding-window check (Redis sorted set)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check_and_record(
|
||||
r: redis.Redis,
|
||||
user_id: str,
|
||||
window: int,
|
||||
effective_limit: int,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Atomically check the user's upload count and record the new upload.
|
||||
|
||||
Uses a Redis sorted set where each member is a unique timestamp-based ID
|
||||
and the score is the Unix timestamp. Entries older than *window* seconds
|
||||
are pruned on every call so the set never grows unbounded.
|
||||
|
||||
Returns:
|
||||
``None`` if the request is allowed, or a ``dict`` with ``count``,
|
||||
``limit``, and ``retry_after`` if the limit is exceeded.
|
||||
"""
|
||||
key = f"{_KEY_PREFIX}:{user_id}"
|
||||
now = time.time()
|
||||
window_start = now - window
|
||||
|
||||
pipe = r.pipeline(transaction=True)
|
||||
# 1. Remove entries outside the window
|
||||
pipe.zremrangebyscore(key, "-inf", window_start)
|
||||
# 2. Count current entries
|
||||
pipe.zcard(key)
|
||||
# 3. Retrieve the oldest entry's score (to compute retry_after)
|
||||
pipe.zrange(key, 0, 0, withscores=True)
|
||||
results = pipe.execute()
|
||||
|
||||
current_count: int = results[1]
|
||||
oldest_entries: list = results[2]
|
||||
|
||||
if current_count >= effective_limit:
|
||||
# Compute how long until the oldest entry expires from the window.
|
||||
if oldest_entries:
|
||||
oldest_score = oldest_entries[0][1]
|
||||
retry_after = max(1, int((oldest_score + window) - now))
|
||||
else:
|
||||
retry_after = max(1, window // 2)
|
||||
return {
|
||||
"count": current_count,
|
||||
"limit": effective_limit,
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
|
||||
# 4. Record this upload (unique member = timestamp with random suffix)
|
||||
member = f"{now}:{os.urandom(4).hex()}"
|
||||
pipe2 = r.pipeline(transaction=True)
|
||||
pipe2.zadd(key, {member: now})
|
||||
pipe2.expire(key, window + 60) # TTL slightly longer than window
|
||||
pipe2.execute()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI dependency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def require_upload_rate_limit(request: Request) -> None:
|
||||
"""FastAPI dependency that enforces per-user upload rate limits.
|
||||
|
||||
The dependency is designed to **fail open**: if Redis is unavailable the
|
||||
request is allowed through so that uploads are never blocked by a
|
||||
monitoring outage.
|
||||
|
||||
Raises:
|
||||
HTTPException: 429 Too Many Requests when the per-user upload limit
|
||||
is exceeded. The ``Retry-After`` header indicates how many
|
||||
seconds the client should wait before retrying.
|
||||
"""
|
||||
r = _get_redis()
|
||||
if r is None:
|
||||
# Redis unavailable – fail open.
|
||||
return
|
||||
|
||||
# Identify the user (owner_id for multi-user, IP fallback).
|
||||
user_id = get_current_owner_id(request)
|
||||
if not user_id:
|
||||
user_id = f"ip:{request.client.host}" if request.client else "ip:unknown"
|
||||
|
||||
base_limit: int = settings.upload_rate_limit_per_user
|
||||
window: int = settings.upload_rate_limit_window
|
||||
|
||||
# Gather health metrics and compute effective limit.
|
||||
try:
|
||||
queue_depth = _get_queue_depth(r)
|
||||
except Exception: # noqa: BLE001
|
||||
queue_depth = 0
|
||||
|
||||
cpu_load_ratio = _get_cpu_load_ratio()
|
||||
effective_limit, factor, health_reason = compute_effective_limit(base_limit, queue_depth, cpu_load_ratio)
|
||||
|
||||
# Sliding-window check.
|
||||
try:
|
||||
rejection = _check_and_record(r, user_id, window, effective_limit)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Upload rate-limit check failed (allowing request): %s", exc)
|
||||
return
|
||||
|
||||
if rejection is not None:
|
||||
retry_after = rejection["retry_after"]
|
||||
logger.warning(
|
||||
"Upload rate limit exceeded: user=%s count=%d/%d window=%ds health=%s retry_after=%ds",
|
||||
user_id,
|
||||
rejection["count"],
|
||||
rejection["limit"],
|
||||
window,
|
||||
health_reason,
|
||||
retry_after,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=(
|
||||
f"Upload rate limit exceeded ({rejection['count']}/{rejection['limit']} "
|
||||
f"in {window}s). Retry after {retry_after}s."
|
||||
),
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
if factor < 1.0:
|
||||
logger.info(
|
||||
"Upload allowed with reduced limit: user=%s effective=%d/%d health=%s",
|
||||
user_id,
|
||||
effective_limit,
|
||||
base_limit,
|
||||
health_reason,
|
||||
)
|
||||
+7
-1
@@ -27,9 +27,11 @@ DocuElevate implements rate limiting to protect against abuse and DoS attacks. R
|
||||
### Default Limits
|
||||
|
||||
- **Default endpoints**: 100 requests per minute
|
||||
- **File upload**: 600 requests per minute
|
||||
- **File upload**: 600 requests per minute (global) + 20 per user per 60 s (per-user, health-aware)
|
||||
- **Authentication**: 10 requests per minute
|
||||
|
||||
**Per-user upload rate limiting**: Upload endpoints (`/api/ui-upload`, `/api/process-url`) enforce a per-user sliding-window limit that adapts to system load. Under heavy queue depth or high CPU usage, the effective limit is reduced automatically. See the [Configuration Guide](ConfigurationGuide.md#per-user-upload-rate-limiting) for details.
|
||||
|
||||
**Note**: Document processing endpoints (OCR, metadata extraction) use built-in queue throttling to control processing rates and prevent upstream API overloads. No additional API-level rate limit is applied to processing endpoints.
|
||||
|
||||
### Rate Limit Headers
|
||||
@@ -53,6 +55,10 @@ RATE_LIMITING_ENABLED=true
|
||||
RATE_LIMIT_DEFAULT=100/minute
|
||||
RATE_LIMIT_UPLOAD=600/minute
|
||||
RATE_LIMIT_AUTH=10/minute
|
||||
|
||||
# Per-user upload rate limiting (health-aware)
|
||||
UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window
|
||||
UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds
|
||||
```
|
||||
|
||||
See [Configuration Guide](ConfigurationGuide.md) for more details.
|
||||
|
||||
@@ -85,6 +85,28 @@ Control how the web UI queues and paces file uploads to avoid overwhelming the b
|
||||
|
||||
**Example**: With `UPLOAD_CONCURRENCY=3` and `UPLOAD_QUEUE_DELAY_MS=500`, a directory of 5,000 files is uploaded ≈ 3 at a time with 500 ms pacing – the backend processes files at its own rate while the queue drains in the background without triggering API rate limits.
|
||||
|
||||
### Per-User Upload Rate Limiting
|
||||
|
||||
Server-side rate limiting that prevents any single user from overwhelming the system with bulk uploads. The limiter uses a Redis-backed sliding window and dynamically adjusts limits based on system health.
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|--------------------------------|------------------------------------------------------------------------------------------------------------------------------|-------------|
|
||||
| `UPLOAD_RATE_LIMIT_PER_USER` | Maximum uploads allowed per user within the sliding window. Effective limit may be reduced under load. | `20` |
|
||||
| `UPLOAD_RATE_LIMIT_WINDOW` | Sliding window size in seconds. | `60` |
|
||||
|
||||
**Health-aware dynamic limiting**: The effective per-user limit is automatically reduced when the system is under heavy load:
|
||||
|
||||
| **System condition** | **Effective limit** | **Trigger** |
|
||||
|--------------------------------|---------------------|--------------------------------|
|
||||
| Normal | 100 % of base | Queue < 50, CPU load normal |
|
||||
| Moderate load | 50 % of base | Queue 50–100 or CPU > 1.5× |
|
||||
| High load | 25 % of base | Queue 100–200 or CPU > 2× |
|
||||
| Critical load | 10 % of base | Queue > 200 or CPU > 3× |
|
||||
|
||||
When a user exceeds the limit, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header. The browser client (see *Client-Side Upload Throttling* above) automatically pauses and retries.
|
||||
|
||||
> **Note**: The limiter fails open — if Redis is unavailable, all uploads are allowed through so that a monitoring outage never blocks document processing.
|
||||
|
||||
### File Upload Size Limits
|
||||
|
||||
**Security Feature**: Control file upload sizes to prevent resource exhaustion attacks. See [SECURITY_AUDIT.md](../SECURITY_AUDIT.md#5-file-upload-size-limits) for security details.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.middleware.upload_rate_limit import compute_effective_limit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for compute_effective_limit (pure function, no Redis needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComputeEffectiveLimit:
|
||||
"""Tests for the health-aware effective-limit calculation."""
|
||||
|
||||
def test_normal_conditions_return_base_limit(self):
|
||||
"""Under normal conditions the full base limit should be returned."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0)
|
||||
assert effective == 20
|
||||
assert factor == 1.0
|
||||
assert reason == "normal"
|
||||
|
||||
def test_moderate_queue_halves_limit(self):
|
||||
"""Queue depth > 50 should halve the base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0)
|
||||
assert effective == 10
|
||||
assert factor == 0.5
|
||||
assert "moderate_queue" in reason
|
||||
|
||||
def test_high_queue_quarters_limit(self):
|
||||
"""Queue depth > 100 should quarter the base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0)
|
||||
assert effective == 5
|
||||
assert factor == 0.25
|
||||
assert "high_queue" in reason
|
||||
|
||||
def test_critical_queue_drops_to_ten_percent(self):
|
||||
"""Queue depth > 200 should drop to 10% of base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0)
|
||||
assert effective == 2
|
||||
assert factor == 0.10
|
||||
assert "critical_queue" in reason
|
||||
|
||||
def test_moderate_cpu_halves_limit(self):
|
||||
"""CPU load ratio > 1.5 should halve the base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8)
|
||||
assert effective == 10
|
||||
assert factor == 0.5
|
||||
assert "moderate_cpu" in reason
|
||||
|
||||
def test_high_cpu_quarters_limit(self):
|
||||
"""CPU load ratio > 2.0 should quarter the base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5)
|
||||
assert effective == 5
|
||||
assert factor == 0.25
|
||||
assert "high_cpu" in reason
|
||||
|
||||
def test_critical_cpu_drops_to_ten_percent(self):
|
||||
"""CPU load ratio > 3.0 should drop to 10% of base limit."""
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0)
|
||||
assert effective == 2
|
||||
assert factor == 0.10
|
||||
assert "critical_cpu" in reason
|
||||
|
||||
def test_worst_metric_wins(self):
|
||||
"""The lowest factor from queue and CPU should be applied."""
|
||||
# Queue says 0.5, CPU says 0.25 → 0.25 wins
|
||||
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5)
|
||||
assert effective == 5
|
||||
assert factor == 0.25
|
||||
|
||||
def test_minimum_effective_limit_is_one(self):
|
||||
"""Even under extreme load the effective limit must be ≥ 1."""
|
||||
effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0)
|
||||
assert effective >= 1
|
||||
|
||||
def test_zero_base_limit_returns_zero(self):
|
||||
"""A base limit of 0 (disabled) should clamp to at least 1."""
|
||||
effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0)
|
||||
# max(1, int(0 * 1.0)) = max(1, 0) = 1
|
||||
# This is correct since a base_limit of 0 means "disabled" and is
|
||||
# handled upstream (the dependency skips the check entirely).
|
||||
assert effective >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for the FastAPI dependency (mocked Redis)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequireUploadRateLimit:
|
||||
"""Tests for the require_upload_rate_limit FastAPI dependency."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_request_when_redis_unavailable(self):
|
||||
"""When Redis is down the dependency should fail open (allow the request)."""
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
with patch("app.middleware.upload_rate_limit._get_redis", return_value=None):
|
||||
# Should NOT raise
|
||||
result = await require_upload_rate_limit(mock_request)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_request_under_limit(self):
|
||||
"""A user below the rate limit should be allowed through."""
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"username": "testuser"}}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "10.0.0.1"
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_pipe = MagicMock()
|
||||
mock_pipe.execute.return_value = [
|
||||
0, # zremrangebyscore result
|
||||
5, # zcard — current count (under limit of 20)
|
||||
[], # zrange oldest
|
||||
]
|
||||
mock_redis.pipeline.return_value = mock_pipe
|
||||
mock_redis.llen.return_value = 0 # empty queues
|
||||
|
||||
mock_pipe2 = MagicMock()
|
||||
mock_pipe2.execute.return_value = [True, True]
|
||||
# The second pipeline call (record upload)
|
||||
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
|
||||
|
||||
with (
|
||||
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
|
||||
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"),
|
||||
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1),
|
||||
):
|
||||
result = await require_upload_rate_limit(mock_request)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_request_over_limit(self):
|
||||
"""A user at or over the rate limit should receive a 429."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"username": "spammer"}}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "10.0.0.2"
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_pipe = MagicMock()
|
||||
mock_pipe.execute.return_value = [
|
||||
0, # zremrangebyscore
|
||||
20, # zcard — at limit
|
||||
[("oldest_entry", 1000000.0)], # oldest entry for retry_after
|
||||
]
|
||||
mock_redis.pipeline.return_value = mock_pipe
|
||||
mock_redis.llen.return_value = 0
|
||||
|
||||
with (
|
||||
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
|
||||
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"),
|
||||
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_upload_rate_limit(mock_request)
|
||||
assert exc_info.value.status_code == 429
|
||||
assert "Retry-After" in exc_info.value.headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_reduces_effective_limit(self):
|
||||
"""When queues are deep, the effective limit should drop, causing a 429 sooner."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"username": "normaluser"}}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "10.0.0.3"
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_pipe = MagicMock()
|
||||
# 12 uploads already — under normal limit of 20 but over health-reduced limit
|
||||
mock_pipe.execute.return_value = [
|
||||
0, # zremrangebyscore
|
||||
12, # zcard — 12 uploads in window
|
||||
[("oldest", 1000000.0)],
|
||||
]
|
||||
mock_redis.pipeline.return_value = mock_pipe
|
||||
# Simulate deep queue (>100) → effective limit = 25% of 20 = 5
|
||||
mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total
|
||||
|
||||
with (
|
||||
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
|
||||
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"),
|
||||
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await require_upload_rate_limit(mock_request)
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_ip_when_no_user(self):
|
||||
"""Unauthenticated requests should use IP-based rate limiting."""
|
||||
from app.middleware.upload_rate_limit import require_upload_rate_limit
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "192.168.1.100"
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_pipe = MagicMock()
|
||||
mock_pipe.execute.return_value = [0, 0, []]
|
||||
mock_redis.pipeline.return_value = mock_pipe
|
||||
mock_redis.llen.return_value = 0
|
||||
|
||||
mock_pipe2 = MagicMock()
|
||||
mock_pipe2.execute.return_value = [True, True]
|
||||
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
|
||||
|
||||
with (
|
||||
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
|
||||
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None),
|
||||
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
|
||||
):
|
||||
result = await require_upload_rate_limit(mock_request)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadRateLimitConfig:
|
||||
"""Tests for upload rate limit configuration settings."""
|
||||
|
||||
def test_settings_exist(self):
|
||||
"""Verify per-user upload rate limit settings are exposed in config."""
|
||||
from app.config import settings
|
||||
|
||||
assert hasattr(settings, "upload_rate_limit_per_user")
|
||||
assert hasattr(settings, "upload_rate_limit_window")
|
||||
|
||||
def test_sensible_defaults(self):
|
||||
"""Default values should be reasonable for a multi-user system."""
|
||||
from app.config import settings
|
||||
|
||||
assert settings.upload_rate_limit_per_user >= 10
|
||||
assert settings.upload_rate_limit_per_user <= 100
|
||||
assert settings.upload_rate_limit_window >= 30
|
||||
assert settings.upload_rate_limit_window <= 300
|
||||
Reference in New Issue
Block a user