feat: add Redis-backed rate limiter with configurable backend
Add RedisRateLimiter using sorted-set sliding window alongside the existing InMemoryRateLimiter. Users choose via RATE_LIMIT_BACKEND (memory|redis) with RATE_LIMIT_REDIS_URL falling back to RQ_REDIS_URL. Redis backend validates connectivity at startup and fails open on transient errors during requests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Abhimanyu Saharan
parent
ee825fb2d5
commit
fc9fc1661c
@@ -10,6 +10,7 @@ from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.core.auth_mode import AuthMode
|
||||
from app.core.rate_limit_backend import RateLimitBackend
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_ENV_FILE = BACKEND_ROOT / ".env"
|
||||
@@ -60,6 +61,10 @@ class Settings(BaseSettings):
|
||||
# Webhook payload size limit in bytes (default 1 MB).
|
||||
webhook_max_payload_bytes: int = 1_048_576
|
||||
|
||||
# Rate limiting
|
||||
rate_limit_backend: RateLimitBackend = RateLimitBackend.MEMORY
|
||||
rate_limit_redis_url: str = ""
|
||||
|
||||
# Database lifecycle
|
||||
db_auto_migrate: bool = False
|
||||
|
||||
@@ -98,6 +103,7 @@ class Settings(BaseSettings):
|
||||
raise ValueError(
|
||||
"LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.",
|
||||
)
|
||||
|
||||
base_url = self.base_url.strip()
|
||||
if not base_url:
|
||||
raise ValueError("BASE_URL must be set and non-empty.")
|
||||
@@ -107,6 +113,15 @@ class Settings(BaseSettings):
|
||||
"BASE_URL must be an absolute http(s) URL (e.g. http://localhost:8000).",
|
||||
)
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
# Rate-limit: fall back to rq_redis_url if using redis backend
|
||||
# with no explicit rate-limit URL.
|
||||
if (
|
||||
self.rate_limit_backend == RateLimitBackend.REDIS
|
||||
and not self.rate_limit_redis_url.strip()
|
||||
):
|
||||
self.rate_limit_redis_url = self.rq_redis_url
|
||||
|
||||
# In dev, default to applying Alembic migrations at startup to avoid
|
||||
# schema drift (e.g. missing newly-added columns).
|
||||
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
"""Simple in-memory sliding-window rate limiter for abuse prevention.
|
||||
"""Sliding-window rate limiters for abuse prevention.
|
||||
|
||||
This provides per-IP rate limiting without external dependencies.
|
||||
Each key maintains a sliding window of recent request timestamps;
|
||||
a request is allowed only when the number of timestamps within the
|
||||
window is below the configured maximum.
|
||||
|
||||
For multi-process or distributed deployments, a Redis-based limiter
|
||||
should be used instead.
|
||||
Supports an in-memory backend (default, no external dependencies) and
|
||||
a Redis-backed backend for multi-process / distributed deployments.
|
||||
Configure via RATE_LIMIT_BACKEND=memory|redis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
import redis as redis_lib
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.rate_limit_backend import RateLimitBackend
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Run a full sweep of all keys every 128 calls to is_allowed.
|
||||
_CLEANUP_INTERVAL = 128
|
||||
|
||||
|
||||
class InMemoryRateLimiter:
|
||||
class RateLimiter(ABC):
|
||||
"""Base interface for sliding-window rate limiters."""
|
||||
|
||||
@abstractmethod
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""Return True if the request should be allowed, False if rate-limited."""
|
||||
|
||||
|
||||
class InMemoryRateLimiter(RateLimiter):
|
||||
"""Sliding-window rate limiter keyed by arbitrary string (typically client IP)."""
|
||||
|
||||
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
|
||||
@@ -61,8 +74,103 @@ class InMemoryRateLimiter:
|
||||
return True
|
||||
|
||||
|
||||
class RedisRateLimiter(RateLimiter):
|
||||
"""Redis-backed sliding-window rate limiter using sorted sets.
|
||||
|
||||
Each key is stored as a Redis sorted set where members are unique
|
||||
request identifiers and scores are wall-clock timestamps. A pipeline
|
||||
prunes expired entries, adds the new request, counts the window, and
|
||||
sets a TTL — all in a single round-trip.
|
||||
|
||||
Fail-open: if Redis is unreachable during a request, the request is
|
||||
allowed and a warning is logged.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
max_requests: int,
|
||||
window_seconds: float,
|
||||
redis_url: str,
|
||||
) -> None:
|
||||
self._namespace = namespace
|
||||
self._max_requests = max_requests
|
||||
self._window_seconds = window_seconds
|
||||
self._client: redis_lib.Redis = redis_lib.Redis.from_url(redis_url)
|
||||
|
||||
def is_allowed(self, key: str) -> bool:
|
||||
"""Return True if the request should be allowed, False if rate-limited."""
|
||||
redis_key = f"ratelimit:{self._namespace}:{key}"
|
||||
now = time.time()
|
||||
cutoff = now - self._window_seconds
|
||||
member = f"{now}:{uuid.uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
pipe = self._client.pipeline(transaction=True)
|
||||
pipe.zremrangebyscore(redis_key, "-inf", cutoff)
|
||||
pipe.zadd(redis_key, {member: now})
|
||||
pipe.zcard(redis_key)
|
||||
pipe.expire(redis_key, int(self._window_seconds) + 1)
|
||||
results = pipe.execute()
|
||||
count: int = results[2]
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"rate_limit.redis.unavailable namespace=%s key=%s",
|
||||
self._namespace,
|
||||
key,
|
||||
exc_info=True,
|
||||
)
|
||||
return True # fail-open
|
||||
|
||||
return count <= self._max_requests
|
||||
|
||||
|
||||
def validate_rate_limit_redis(redis_url: str) -> None:
|
||||
"""Verify Redis is reachable. Raises ``ConnectionError`` on failure."""
|
||||
client = redis_lib.Redis.from_url(redis_url)
|
||||
try:
|
||||
client.ping()
|
||||
except Exception as exc:
|
||||
raise ConnectionError(
|
||||
f"Redis rate-limit backend configured but unreachable at {redis_url}: {exc}",
|
||||
) from exc
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def create_rate_limiter(
|
||||
*,
|
||||
namespace: str,
|
||||
max_requests: int,
|
||||
window_seconds: float,
|
||||
) -> RateLimiter:
|
||||
"""Create a rate limiter based on the configured backend."""
|
||||
from app.core.config import settings
|
||||
|
||||
if settings.rate_limit_backend == RateLimitBackend.REDIS:
|
||||
return RedisRateLimiter(
|
||||
namespace=namespace,
|
||||
max_requests=max_requests,
|
||||
window_seconds=window_seconds,
|
||||
redis_url=settings.rate_limit_redis_url,
|
||||
)
|
||||
return InMemoryRateLimiter(
|
||||
max_requests=max_requests,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
|
||||
# Shared limiter instances for specific endpoints.
|
||||
# Agent auth: 20 attempts per 60 seconds per IP.
|
||||
agent_auth_limiter = InMemoryRateLimiter(max_requests=20, window_seconds=60.0)
|
||||
agent_auth_limiter: RateLimiter = create_rate_limiter(
|
||||
namespace="agent_auth",
|
||||
max_requests=20,
|
||||
window_seconds=60.0,
|
||||
)
|
||||
# Webhook ingest: 60 requests per 60 seconds per IP.
|
||||
webhook_ingest_limiter = InMemoryRateLimiter(max_requests=60, window_seconds=60.0)
|
||||
webhook_ingest_limiter: RateLimiter = create_rate_limiter(
|
||||
namespace="webhook_ingest",
|
||||
max_requests=60,
|
||||
window_seconds=60.0,
|
||||
)
|
||||
|
||||
12
backend/app/core/rate_limit_backend.py
Normal file
12
backend/app/core/rate_limit_backend.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Rate-limit backend selection enum."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RateLimitBackend(str, Enum):
|
||||
"""Supported rate-limiting backends."""
|
||||
|
||||
MEMORY = "memory"
|
||||
REDIS = "redis"
|
||||
Reference in New Issue
Block a user