|
| 1 | +""" |
| 2 | +Rate Limiter Service - Per-User Request Throttling |
| 3 | +
|
| 4 | +Implements simple in-memory rate limiting to prevent abuse. |
| 5 | +
|
| 6 | +CONFIGURATION: |
| 7 | + - Default: 60 requests per minute per user |
| 8 | + - Configurable via environment variables |
| 9 | +
|
| 10 | +DESIGN: |
| 11 | + - In-memory storage (resets on server restart) |
| 12 | + - Keyed by user_id for multi-tenant isolation |
| 13 | + - Sliding window algorithm |
| 14 | +""" |
| 15 | +import time |
| 16 | +import os |
| 17 | +from collections import defaultdict |
| 18 | +from threading import Lock |
| 19 | +import logging |
| 20 | + |
| 21 | +logger = logging.getLogger(__name__) |
| 22 | + |
| 23 | +# Configuration |
| 24 | +RATE_LIMIT_REQUESTS = int(os.getenv('RATE_LIMIT_REQUESTS', '60')) |
| 25 | +RATE_LIMIT_WINDOW_SECONDS = int(os.getenv('RATE_LIMIT_WINDOW', '60')) |
| 26 | + |
| 27 | + |
| 28 | +class RateLimiter: |
| 29 | + """ |
| 30 | + Thread-safe per-user rate limiter using sliding window. |
| 31 | + |
| 32 | + MULTI-TENANT ISOLATION: |
| 33 | + - Each user has their own request counter |
| 34 | + - No cross-user rate limit sharing |
| 35 | + """ |
| 36 | + |
| 37 | + def __init__(self, max_requests: int = RATE_LIMIT_REQUESTS, |
| 38 | + window_seconds: int = RATE_LIMIT_WINDOW_SECONDS): |
| 39 | + self.max_requests = max_requests |
| 40 | + self.window_seconds = window_seconds |
| 41 | + self.requests = defaultdict(list) # user_id -> [timestamps] |
| 42 | + self.lock = Lock() |
| 43 | + |
| 44 | + def is_allowed(self, user_id: str) -> tuple[bool, dict]: |
| 45 | + """ |
| 46 | + Check if a request is allowed for a user. |
| 47 | + |
| 48 | + Args: |
| 49 | + user_id: Clerk user ID (tenant isolation key) |
| 50 | + |
| 51 | + Returns: |
| 52 | + (allowed: bool, info: dict with remaining, reset_at, etc.) |
| 53 | + |
| 54 | + SECURITY: Uses user_id to ensure rate limits are per-tenant. |
| 55 | + """ |
| 56 | + if not user_id: |
| 57 | + # Anonymous requests - use stricter limit |
| 58 | + user_id = "anonymous" |
| 59 | + |
| 60 | + current_time = time.time() |
| 61 | + window_start = current_time - self.window_seconds |
| 62 | + |
| 63 | + with self.lock: |
| 64 | + # Clean old requests |
| 65 | + self.requests[user_id] = [ |
| 66 | + ts for ts in self.requests[user_id] |
| 67 | + if ts > window_start |
| 68 | + ] |
| 69 | + |
| 70 | + request_count = len(self.requests[user_id]) |
| 71 | + remaining = max(0, self.max_requests - request_count) |
| 72 | + |
| 73 | + if request_count >= self.max_requests: |
| 74 | + # Rate limited |
| 75 | + oldest = self.requests[user_id][0] if self.requests[user_id] else current_time |
| 76 | + reset_at = oldest + self.window_seconds |
| 77 | + return False, { |
| 78 | + "allowed": False, |
| 79 | + "remaining": 0, |
| 80 | + "limit": self.max_requests, |
| 81 | + "reset_at": int(reset_at), |
| 82 | + "retry_after": int(reset_at - current_time) |
| 83 | + } |
| 84 | + |
| 85 | + # Allow and record |
| 86 | + self.requests[user_id].append(current_time) |
| 87 | + |
| 88 | + return True, { |
| 89 | + "allowed": True, |
| 90 | + "remaining": remaining - 1, |
| 91 | + "limit": self.max_requests, |
| 92 | + "reset_at": int(current_time + self.window_seconds) |
| 93 | + } |
| 94 | + |
| 95 | + def get_status(self, user_id: str) -> dict: |
| 96 | + """Get current rate limit status for a user without consuming quota.""" |
| 97 | + current_time = time.time() |
| 98 | + window_start = current_time - self.window_seconds |
| 99 | + |
| 100 | + with self.lock: |
| 101 | + requests = [ts for ts in self.requests.get(user_id, []) if ts > window_start] |
| 102 | + remaining = max(0, self.max_requests - len(requests)) |
| 103 | + |
| 104 | + return { |
| 105 | + "remaining": remaining, |
| 106 | + "limit": self.max_requests, |
| 107 | + "window_seconds": self.window_seconds, |
| 108 | + "used": len(requests) |
| 109 | + } |
| 110 | + |
| 111 | + |
| 112 | +# Global rate limiter instance |
| 113 | +rate_limiter = RateLimiter() |
| 114 | + |
| 115 | + |
| 116 | +def check_rate_limit(user_id: str) -> tuple[bool, dict]: |
| 117 | + """ |
| 118 | + Convenience function to check rate limit. |
| 119 | + |
| 120 | + Returns: |
| 121 | + (allowed: bool, info: dict) |
| 122 | + """ |
| 123 | + return rate_limiter.is_allowed(user_id) |
| 124 | + |
| 125 | + |
| 126 | +def get_rate_limit_status(user_id: str) -> dict: |
| 127 | + """Get rate limit status without consuming quota.""" |
| 128 | + return rate_limiter.get_status(user_id) |
0 commit comments