Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/core/api/ratelimit.py

157 lines
5.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2025 Allaun
"""Rate Limiting Module.
Provides rate limiting functionality using sliding window algorithm.
"""
from __future__ import annotations
import functools
import time
from collections import deque
from typing import Any, Callable, Deque, Dict, Optional
class RateLimiter:
"""Sliding Window Rate Limiter.
Implements rate limiting using a sliding window algorithm to track
request timestamps and enforce rate limits.
"""
def __init__(self, requests_per_minute: int = 60) -> None:
"""Initialize rate limiter.
Args:
requests_per_minute: Maximum number of requests allowed per minute.
Default is 60 requests.
"""
self.requests_per_minute = requests_per_minute
self.window_size: float = 60.0
self._requests: Dict[str, Deque[float]] = {}
def check_rate_limit(self, client_id: Optional[str] = None) -> bool:
"""Check if request is within rate limit.
Uses a sliding window algorithm to track request timestamps and
determine if the current request should be allowed.
Args:
client_id: Optional client identifier for per-client rate limiting.
If None, uses a default key.
Returns:
True if the request is allowed, False if rate limit is exceeded.
"""
key = client_id or "default"
current_time = time.time()
if key not in self._requests:
self._requests[key] = deque()
window_start = current_time - self.window_size
while self._requests[key] and self._requests[key][0] <= window_start:
self._requests[key].popleft()
if len(self._requests[key]) < self.requests_per_minute:
self._requests[key].append(current_time)
return True
return False
def throttle(self, client_id: Optional[str] = None) -> float:
"""Get wait time until next request is allowed.
Calculates how long a client must wait before their next request
will be allowed under the rate limit.
Args:
client_id: Optional client identifier. If None, uses a default key.
Returns:
Time in seconds to wait before the next request is allowed.
Returns 0.0 if no waiting is required.
"""
key = client_id or "default"
if key not in self._requests or not self._requests[key]:
return 0.0
oldest = self._requests[key][0]
current_time = time.time()
window_start = current_time - self.window_size
if oldest < window_start:
return 0.0
return oldest + self.window_size - current_time + 0.1
class RateLimitExceeded(Exception):
"""Exception raised when rate limit is exceeded."""
def __init__(self, message: str, retry_after: float = 0.0) -> None:
"""Initialize rate limit exception.
Args:
message: Error message describing the rate limit violation.
retry_after: Number of seconds the client should wait before retrying.
"""
self.message = message
self.retry_after = retry_after
super().__init__(self.message)
def rate_limited(requests_per_minute: int = 60) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to apply rate limiting to a function.
Wraps a function with rate limiting using the RateLimiter class.
Raises RateLimitExceeded if the rate limit is exceeded.
Args:
requests_per_minute: Maximum number of requests allowed per minute.
Default is 60 requests.
Returns:
A decorator function that applies rate limiting to the wrapped function.
Raises:
RateLimitExceeded: If the rate limit is exceeded.
"""
limiter = RateLimiter(requests_per_minute=requests_per_minute)
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that wraps a function with rate limiting.
Args:
func: The function to be wrapped with rate limiting.
Returns:
The wrapped function with rate limiting applied.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrapper function that enforces rate limiting.
Checks the rate limit before calling the wrapped function.
Raises RateLimitExceeded if the rate limit is exceeded.
Args:
*args: Positional arguments passed to the wrapped function.
**kwargs: Keyword arguments passed to the wrapped function.
Returns:
The result of the wrapped function if rate limit is not exceeded.
Raises:
RateLimitExceeded: If the rate limit is exceeded.
"""
if not limiter.check_rate_limit():
wait_time = limiter.throttle()
raise RateLimitExceeded(f"Rate limit exceeded. Try again in {wait_time:.1f} seconds.", retry_after=wait_time)
return func(*args, **kwargs)
return wrapper
return decorator