Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Redis-first rate limiter with in-memory fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_buckets: dict[str, list[datetime]] = defaultdict(list)
|
||||
_redis_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis_client() -> Redis | None:
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
return _redis_client
|
||||
try:
|
||||
_redis_client = Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
_redis_client.ping()
|
||||
return _redis_client
|
||||
except RedisError:
|
||||
_redis_client = None
|
||||
return None
|
||||
|
||||
|
||||
def check_rate_limit(key: str, limit: int, window_seconds: int) -> None:
|
||||
if not settings.enable_rate_limit:
|
||||
return
|
||||
redis_client = get_redis_client()
|
||||
if redis_client is not None:
|
||||
redis_key = f"rl:{key}"
|
||||
try:
|
||||
current = redis_client.incr(redis_key)
|
||||
if current == 1:
|
||||
redis_client.expire(redis_key, window_seconds)
|
||||
if current > limit:
|
||||
ttl = redis_client.ttl(redis_key)
|
||||
retry_after = ttl if ttl and ttl > 0 else window_seconds
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
except RedisError:
|
||||
pass
|
||||
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(seconds=window_seconds)
|
||||
timestamps = [moment for moment in _buckets[key] if moment > cutoff]
|
||||
if len(timestamps) >= limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="RATE_LIMIT_EXCEEDED",
|
||||
headers={"Retry-After": str(window_seconds)},
|
||||
)
|
||||
timestamps.append(now)
|
||||
_buckets[key] = timestamps
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()}
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
request_ip = request.client.host if request.client else ""
|
||||
if forwarded and request_ip in trusted_proxy_ips:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request_ip
|
||||
return "unknown"
|
||||
Reference in New Issue
Block a user