1a195a96bd
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
291 lines
9.9 KiB
Python
291 lines
9.9 KiB
Python
"""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", exc_info=True)
|
||
_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 %r", queue_name, exc_info=True)
|
||
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,
|
||
)
|