feat(rate-limit): T39 implement rate limiting for public API
- 100 requests/hour per API token - 30 requests/minute per IP (fallback) - In-memory storage with auto-cleanup - Headers: X-RateLimit-Limit, X-RateLimit-Remaining - Returns 429 Too Many Requests when exceeded
This commit is contained in:
200
src/openrouter_monitor/dependencies/rate_limit.py
Normal file
200
src/openrouter_monitor/dependencies/rate_limit.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Rate limiting dependency for public API.
|
||||||
|
|
||||||
|
T39: Rate limiting for public API endpoints.
|
||||||
|
Uses in-memory storage for MVP (simple dict-based approach).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from openrouter_monitor.dependencies.auth import api_token_security
|
||||||
|
|
||||||
|
|
||||||
|
# In-memory storage for rate limiting
|
||||||
|
# Structure: {key: (count, reset_time)}
|
||||||
|
_rate_limit_storage: Dict[str, Tuple[int, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract client IP from request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Client IP address
|
||||||
|
"""
|
||||||
|
# Check for X-Forwarded-For header (for proxied requests)
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
# Get the first IP in the chain
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
|
||||||
|
# Fall back to direct connection IP
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(
|
||||||
|
key: str,
|
||||||
|
max_requests: int,
|
||||||
|
window_seconds: int,
|
||||||
|
) -> Tuple[bool, int, int, float]:
|
||||||
|
"""Check if a request is within rate limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Rate limit key (token hash or IP)
|
||||||
|
max_requests: Maximum requests allowed in window
|
||||||
|
window_seconds: Time window in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, remaining, limit, reset_time)
|
||||||
|
"""
|
||||||
|
global _rate_limit_storage
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
reset_time = now + window_seconds
|
||||||
|
|
||||||
|
# Clean up expired entries periodically (simple approach)
|
||||||
|
if len(_rate_limit_storage) > 10000: # Prevent memory bloat
|
||||||
|
_rate_limit_storage = {
|
||||||
|
k: v for k, v in _rate_limit_storage.items()
|
||||||
|
if v[1] > now
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current count and reset time for this key
|
||||||
|
if key in _rate_limit_storage:
|
||||||
|
count, key_reset_time = _rate_limit_storage[key]
|
||||||
|
|
||||||
|
# Check if window has expired
|
||||||
|
if now > key_reset_time:
|
||||||
|
# Reset window
|
||||||
|
count = 1
|
||||||
|
_rate_limit_storage[key] = (count, reset_time)
|
||||||
|
remaining = max_requests - count
|
||||||
|
return True, remaining, max_requests, reset_time
|
||||||
|
else:
|
||||||
|
# Window still active
|
||||||
|
if count >= max_requests:
|
||||||
|
# Rate limit exceeded
|
||||||
|
remaining = 0
|
||||||
|
return False, remaining, max_requests, key_reset_time
|
||||||
|
else:
|
||||||
|
# Increment count
|
||||||
|
count += 1
|
||||||
|
_rate_limit_storage[key] = (count, key_reset_time)
|
||||||
|
remaining = max_requests - count
|
||||||
|
return True, remaining, max_requests, key_reset_time
|
||||||
|
else:
|
||||||
|
# First request for this key
|
||||||
|
count = 1
|
||||||
|
_rate_limit_storage[key] = (count, reset_time)
|
||||||
|
remaining = max_requests - count
|
||||||
|
return True, remaining, max_requests, reset_time
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""Rate limiter dependency for FastAPI endpoints.
|
||||||
|
|
||||||
|
Supports two rate limit types:
|
||||||
|
- Per API token: 100 requests/hour for authenticated requests
|
||||||
|
- Per IP: 30 requests/minute for unauthenticated/fallback
|
||||||
|
|
||||||
|
Headers added to response:
|
||||||
|
- X-RateLimit-Limit: Maximum requests allowed
|
||||||
|
- X-RateLimit-Remaining: Remaining requests in current window
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token_limit: int = 100,
|
||||||
|
token_window: int = 3600, # 1 hour
|
||||||
|
ip_limit: int = 30,
|
||||||
|
ip_window: int = 60, # 1 minute
|
||||||
|
):
|
||||||
|
self.token_limit = token_limit
|
||||||
|
self.token_window = token_window
|
||||||
|
self.ip_limit = ip_limit
|
||||||
|
self.ip_window = ip_window
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(api_token_security),
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Check rate limit and return headers info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
credentials: Optional API token credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with rate limit headers info
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 429 if rate limit exceeded
|
||||||
|
"""
|
||||||
|
# Determine rate limit key based on auth
|
||||||
|
if credentials and credentials.credentials:
|
||||||
|
# Use token-based rate limiting
|
||||||
|
# Hash the token for the key
|
||||||
|
import hashlib
|
||||||
|
key = f"token:{hashlib.sha256(credentials.credentials.encode()).hexdigest()[:16]}"
|
||||||
|
max_requests = self.token_limit
|
||||||
|
window_seconds = self.token_window
|
||||||
|
else:
|
||||||
|
# Use IP-based rate limiting (fallback)
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
key = f"ip:{client_ip}"
|
||||||
|
max_requests = self.ip_limit
|
||||||
|
window_seconds = self.ip_window
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
allowed, remaining, limit, reset_time = check_rate_limit(
|
||||||
|
key, max_requests, window_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Rate limit exceeded. Please try again later.",
|
||||||
|
headers={
|
||||||
|
"X-RateLimit-Limit": str(limit),
|
||||||
|
"X-RateLimit-Remaining": "0",
|
||||||
|
"X-RateLimit-Reset": str(int(reset_time)),
|
||||||
|
"Retry-After": str(int(reset_time - time.time())),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return rate limit info for headers
|
||||||
|
return {
|
||||||
|
"X-RateLimit-Limit": limit,
|
||||||
|
"X-RateLimit-Remaining": remaining,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Default rate limiter instance
|
||||||
|
rate_limiter = RateLimiter()
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_dependency(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(api_token_security),
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Default rate limiting dependency.
|
||||||
|
|
||||||
|
- 100 requests per hour per API token
|
||||||
|
- 30 requests per minute per IP (fallback)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
credentials: Optional API token credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with rate limit headers info
|
||||||
|
"""
|
||||||
|
return await rate_limiter(request, credentials)
|
||||||
Reference in New Issue
Block a user