diff --git a/RATE_LIMITING.md b/RATE_LIMITING.md new file mode 100644 index 00000000..820d4ffe --- /dev/null +++ b/RATE_LIMITING.md @@ -0,0 +1,367 @@ +# Rate Limiting Documentation + +## Overview + +ResCanvas implements comprehensive API rate limiting to protect backend services from abuse, ensure fair resource allocation, and maintain system stability. The rate limiting system uses Redis for distributed counters and Flask-Limiter for enforcement. + +## Rate Limit Tiers + +### Global Limits (Per IP) + +- **Anonymous users**: 1000 requests/hour +- **Authenticated users**: 5000 requests/hour + +These limits apply to all API endpoints as a baseline. + +### Authentication Endpoints + +| Endpoint | Limit | Reason | +|----------|-------|--------| +| POST /auth/login | 100/hour | Prevent brute force attacks | +| POST /auth/register | 50/hour | Prevent spam accounts | +| POST /auth/refresh | 200/hour | Normal token refresh patterns | + +### Stroke Operations + +| Endpoint | Limit | Reason | +|----------|-------|--------| +| POST /submitNewLineRoom | 300/minute | Active drawing sessions | +| POST /rooms/\/strokes | 300/minute | Alternative stroke endpoint | +| POST /rooms/\/undo | 60/minute | Reasonable undo frequency | +| POST /rooms/\/redo | 60/minute | Reasonable redo frequency | + +### Room Operations + +| Endpoint | Limit | Reason | +|----------|-------|--------| +| POST /rooms | 10/hour | Prevent room spam | +| POST /submitClearCanvasTimestamp | 5/minute per room | Prevent clear spam | +| PUT /rooms/\ | 20/minute | Normal editing frequency | + +### Search & Discovery + +| Endpoint | Limit | Reason | +|----------|-------|--------| +| GET /users/suggest | 30/minute | User search | +| GET /rooms/suggest | 30/minute | Room search | + +## Configuration + +### Environment Variables + +All rate limits can be customized via environment variables: + +```bash +# Enable/disable rate limiting +RATE_LIMIT_ENABLED=True + +# Redis storage for rate limit counters +RATE_LIMIT_STORAGE=redis://localhost:6379 + +# Global limits +RATE_LIMIT_GLOBAL_HOURLY=1000 +RATE_LIMIT_GLOBAL_AUTH_HOURLY=5000 + +# Authentication endpoints +RATE_LIMIT_LOGIN_HOURLY=100 +RATE_LIMIT_REGISTER_HOURLY=50 +RATE_LIMIT_REFRESH_HOURLY=200 + +# Stroke operations +RATE_LIMIT_STROKE_MINUTE=300 +RATE_LIMIT_UNDO_REDO_MINUTE=60 + +# Room operations +RATE_LIMIT_ROOM_CREATE_HOURLY=10 +RATE_LIMIT_ROOM_CLEAR_MINUTE=5 +RATE_LIMIT_ROOM_UPDATE_MINUTE=20 + +# Search and discovery +RATE_LIMIT_SEARCH_MINUTE=30 + +# Burst protection +RATE_LIMIT_BURST_SECOND=10 +``` + +### Disabling Rate Limiting + +For development or testing, rate limiting can be disabled: + +```bash +RATE_LIMIT_ENABLED=False +``` + +## Response Headers + +All API responses include rate limit headers: + +```http +X-RateLimit-Limit: 300 # Maximum requests allowed +X-RateLimit-Remaining: 245 # Requests remaining in window +X-RateLimit-Reset: 1730000000 # Unix timestamp when limit resets +``` + +When rate limited (429 response), additional header: + +```http +Retry-After: 45 # Seconds to wait before retrying +``` + +## Error Response Format + +When rate limit is exceeded (HTTP 429): + +```json +{ + "status": "error", + "error": "rate_limit_exceeded", + "message": "Rate limit exceeded. Please try again in 45 seconds." +} +``` + +## Frontend Integration + +### Using the API Client + +The enhanced API client (`frontend/src/api/apiClient.js`) automatically handles rate limits: + +```javascript +import apiClient from './api/apiClient'; + +try { + const room = await apiClient.post('/rooms', { + name: 'My Room', + type: 'public' + }); +} catch (error) { + if (error.status === 429) { + // Rate limited - auto-retry is already attempted + console.log('Rate limited:', error.message); + } +} +``` + +### Displaying Rate Limit Warnings + +Use the `RateLimitWarning` component: + +```javascript +import RateLimitWarning from './components/RateLimitWarning'; + +function MyComponent() { + const [rateLimitInfo, setRateLimitInfo] = useState(null); + + try { + await apiClient.post('/rooms//strokes', strokeData); + } catch (error) { + if (error.status === 429) { + setRateLimitInfo({ + exceeded: true, + limit: error.rateLimitInfo?.limit, + remaining: 0, + reset: error.rateLimitInfo?.reset, + }); + } + } + + return ( +
+ {rateLimitInfo && ( + setRateLimitInfo(null)} + /> + )} + {/* Your component content */} +
+ ); +} +``` + +### Automatic Retry with Backoff + +The rate limit handler includes automatic retry: + +```javascript +import { retryWithBackoff } from './utils/rateLimitHandler'; + +const result = await retryWithBackoff( + async () => { + return await apiClient.post('/endpoint', data); + }, + { + maxAttempts: 3, + baseDelay: 1000, + onRetry: (attempt, delay) => { + console.log(`Retrying attempt ${attempt} after ${delay}ms`); + } + } +); +``` + +## Backend Implementation + +### Adding Rate Limits to New Endpoints + +1. **Import the limiter**: + +```python +from middleware.rate_limit import limiter +from config import RATE_LIMIT_STROKE_MINUTE +``` + +2. **Apply decorator**: + +```python +@my_blueprint.route('/my-endpoint', methods=['POST']) +@require_auth +@limiter.limit(f"{RATE_LIMIT_STROKE_MINUTE}/minute") +def my_endpoint(): + # Your endpoint logic + pass +``` + +3. **Room-specific limits**: + +```python +@limiter.limit("5/minute", key_func=lambda: request.get_json().get('roomId')) +def room_specific_endpoint(): + pass +``` + +### Custom Rate Limit Functions + +For complex scenarios: + +```python +from middleware.rate_limit import user_rate_limit, room_specific_limit + +@my_blueprint.route('/endpoint', methods=['POST']) +@user_rate_limit("100/hour") # Per authenticated user +def my_endpoint(): + pass +``` + +## Monitoring & Logging + +### Rate Limit Violations + +Rate limit violations are automatically logged: + +``` +WARNING: Rate limit exceeded: user_id=abc123, ip=192.168.1.1, endpoint=submit_stroke, method=POST +``` + +### Redis Inspection + +Check current rate limit counters: + +```bash +redis-cli +> KEYS LIMITER/* +> GET LIMITER/ +``` + +### Metrics + +Monitor rate limit hits in your logging/metrics system. Key metrics: + +- Number of 429 responses per endpoint +- Top rate-limited IPs/users +- Rate limit reset frequency +- Average retry attempts + +## Testing + +### Unit Tests + +Run rate limiting tests: + +```bash +cd backend +pytest tests/test_rate_limiting.py -v +``` + +### Manual Testing + +Test rate limits with rapid requests: + +```bash +# Test login rate limit (should block after 100 requests) +for i in {1..105}; do + curl -X POST http://localhost:10010/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"test"}' + echo "" +done +``` + +## Best Practices + +### For Developers + +1. **Always check response headers** - Monitor `X-RateLimit-Remaining` +2. **Implement retry logic** - Use exponential backoff +3. **Handle 429 gracefully** - Show user-friendly messages +4. **Queue requests** - Don't spam the API +5. **Cache responses** - Reduce unnecessary requests + +### For Operations + +1. **Monitor Redis** - Ensure rate limit counters are working +2. **Adjust limits** - Based on actual usage patterns +3. **Set alerts** - For abnormal rate limit hits +4. **Log violations** - Track abuse patterns + +## Troubleshooting + +### Rate Limits Not Working + +1. Check Redis connection: + ```bash + redis-cli ping + ``` + +2. Verify environment variables: + ```bash + echo $RATE_LIMIT_ENABLED + echo $RATE_LIMIT_STORAGE + ``` + +3. Check limiter initialization in `app.py` + +### False Positives + +If legitimate users are being rate limited: + +1. Check if limits are too restrictive +2. Verify IP detection is working correctly +3. Consider whitelisting specific IPs/users +4. Adjust limits via environment variables + +### Redis Memory Issues + +If Redis memory is growing: + +1. Rate limit keys expire automatically +2. Check TTL on keys: `redis-cli TTL LIMITER/` +3. Consider separate Redis instance for rate limiting + +## Security Considerations + +1. **Rate limits are server-side** - Never trust client-side throttling +2. **Per-IP tracking** - Prevents distributed abuse +3. **Authentication-aware** - Different limits for authenticated users +4. **Bypass protection** - Limits cannot be circumvented client-side +5. **Graceful degradation** - If Redis fails, requests still succeed (swallow_errors=True) + +## Future Enhancements + +Potential improvements: + +- [ ] Adaptive rate limiting based on server load +- [ ] Whitelist/blacklist support +- [ ] Per-user custom limits +- [ ] Geographic rate limiting +- [ ] Real-time rate limit dashboard +- [ ] Machine learning for abuse detection diff --git a/backend/.env.example b/backend/.env.example index e8208e2f..5512942a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,35 @@ MONGO_ATLAS_URI=MONGO_ATLAS_URI SIGNER_PUBLIC_KEY=SIGNER_PUBLIC_KEY SIGNER_PRIVATE_KEY=SIGNER_PRIVATE_KEY RESILIENTDB_BASE_URI=RESILIENTDB_BASE_URI -RESILIENTDB_GRAPHQL_URI=RESILIENTDB_GRAPHQL_URI \ No newline at end of file +RESILIENTDB_GRAPHQL_URI=RESILIENTDB_GRAPHQL_URI + +# ==================== RATE LIMITING ==================== +# Enable/disable rate limiting globally +RATE_LIMIT_ENABLED=True + +# Redis storage for distributed rate limiting (required if enabled) +RATE_LIMIT_STORAGE=redis://localhost:6379 + +# Global rate limits (per IP for anonymous, per user for authenticated) +RATE_LIMIT_GLOBAL_HOURLY=1000 +RATE_LIMIT_GLOBAL_AUTH_HOURLY=5000 + +# Authentication endpoints +RATE_LIMIT_LOGIN_HOURLY=100 +RATE_LIMIT_REGISTER_HOURLY=50 +RATE_LIMIT_REFRESH_HOURLY=200 + +# Stroke operations (drawing, undo, redo) +RATE_LIMIT_STROKE_MINUTE=300 +RATE_LIMIT_UNDO_REDO_MINUTE=60 + +# Room operations +RATE_LIMIT_ROOM_CREATE_HOURLY=10 +RATE_LIMIT_ROOM_UPDATE_MINUTE=20 +RATE_LIMIT_ROOM_CLEAR_MINUTE=5 + +# Search operations +RATE_LIMIT_SEARCH_MINUTE=30 + +# Burst protection +RATE_LIMIT_BURST_SECOND=10 \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index f2c64c81..43679863 100644 --- a/backend/app.py +++ b/backend/app.py @@ -21,6 +21,25 @@ from config import * app = Flask(__name__) + +# Initialize rate limiting BEFORE registering routes +from middleware.rate_limit import init_limiter, rate_limit_error_handler +limiter = init_limiter(app) + +# Register custom rate limit error handler +@app.errorhandler(429) +def handle_rate_limit_error(e): + """Handle rate limit exceeded errors with proper CORS headers.""" + response = rate_limit_error_handler(e) + + # Ensure CORS headers are present + origin = request.headers.get("Origin") + if origin and origin_allowed(origin): + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + + return response + env_allowed = os.environ.get('ALLOWED_ORIGINS', '') explicit_allowed = [o.strip() for o in env_allowed.split(',') if o.strip()] diff --git a/backend/config.py b/backend/config.py index 827d2346..dc6551dd 100644 --- a/backend/config.py +++ b/backend/config.py @@ -32,4 +32,32 @@ ROOM_MASTER_KEY_B64 = os.getenv("ROOM_MASTER_KEY_B64") if not ROOM_MASTER_KEY_B64: import os, base64 - ROOM_MASTER_KEY_B64 = base64.b64encode(os.urandom(32)).decode() \ No newline at end of file + ROOM_MASTER_KEY_B64 = base64.b64encode(os.urandom(32)).decode() + +# Rate Limiting Configuration +RATE_LIMIT_STORAGE_URI = os.getenv("RATE_LIMIT_STORAGE", "redis://localhost:6379") +RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "True") == "True" + +# Global rate limits (per IP) +RATE_LIMIT_GLOBAL_HOURLY = int(os.getenv("RATE_LIMIT_GLOBAL_HOURLY", "1000")) +RATE_LIMIT_GLOBAL_AUTH_HOURLY = int(os.getenv("RATE_LIMIT_GLOBAL_AUTH_HOURLY", "5000")) + +# Authentication endpoints +RATE_LIMIT_LOGIN_HOURLY = int(os.getenv("RATE_LIMIT_LOGIN_HOURLY", "100")) +RATE_LIMIT_REGISTER_HOURLY = int(os.getenv("RATE_LIMIT_REGISTER_HOURLY", "50")) +RATE_LIMIT_REFRESH_HOURLY = int(os.getenv("RATE_LIMIT_REFRESH_HOURLY", "200")) + +# Stroke operations +RATE_LIMIT_STROKE_MINUTE = int(os.getenv("RATE_LIMIT_STROKE_MINUTE", "300")) +RATE_LIMIT_UNDO_REDO_MINUTE = int(os.getenv("RATE_LIMIT_UNDO_REDO_MINUTE", "60")) + +# Room operations +RATE_LIMIT_ROOM_CREATE_HOURLY = int(os.getenv("RATE_LIMIT_ROOM_CREATE_HOURLY", "10")) +RATE_LIMIT_ROOM_CLEAR_MINUTE = int(os.getenv("RATE_LIMIT_ROOM_CLEAR_MINUTE", "5")) +RATE_LIMIT_ROOM_UPDATE_MINUTE = int(os.getenv("RATE_LIMIT_ROOM_UPDATE_MINUTE", "20")) + +# Search and discovery +RATE_LIMIT_SEARCH_MINUTE = int(os.getenv("RATE_LIMIT_SEARCH_MINUTE", "30")) + +# Burst protection +RATE_LIMIT_BURST_SECOND = int(os.getenv("RATE_LIMIT_BURST_SECOND", "10")) \ No newline at end of file diff --git a/backend/middleware/rate_limit.py b/backend/middleware/rate_limit.py new file mode 100644 index 00000000..cddea471 --- /dev/null +++ b/backend/middleware/rate_limit.py @@ -0,0 +1,244 @@ +# backend/middleware/rate_limit.py +""" +Comprehensive rate limiting middleware for ResCanvas API. + +This module provides multi-tier rate limiting to protect against: +- DoS/DDoS attacks +- Brute force login attempts +- Resource exhaustion +- API abuse + +Rate limits are enforced at multiple levels: +1. Global limits (per IP) +2. Endpoint-specific limits +3. User-specific limits (for authenticated users) +4. Burst protection + +The middleware integrates with Redis for distributed rate limit counters, +ensuring limits work across multiple backend instances. +""" + +from functools import wraps +from flask import request, jsonify, g, current_app +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +import jwt +import logging +from datetime import datetime, timezone +from config import ( + JWT_SECRET, + RATE_LIMIT_STORAGE_URI, + RATE_LIMIT_ENABLED, + RATE_LIMIT_GLOBAL_HOURLY, + RATE_LIMIT_GLOBAL_AUTH_HOURLY, +) + +logger = logging.getLogger(__name__) + +# Global limiter instance (initialized in app.py) +limiter = None + + +def get_user_identifier(): + """ + Get unique identifier for rate limiting. + + Returns: + - User ID (for authenticated users) + - IP address (for anonymous users) + + This allows different rate limits for authenticated vs anonymous users. + """ + # Try to extract user from JWT token + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header.split(' ', 1)[1] + try: + claims = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + user_id = claims.get('sub') + if user_id: + return f"user:{user_id}" + except Exception: + pass + + # Fallback to IP address for anonymous users + return f"ip:{get_remote_address()}" + + +def get_authenticated_user_id(): + """ + Get user ID if authenticated, otherwise None. + Used for user-specific rate limits. + """ + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header.split(' ', 1)[1] + try: + claims = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return claims.get('sub') + except Exception: + pass + return None + + +def is_authenticated(): + """Check if the current request is from an authenticated user.""" + return get_authenticated_user_id() is not None + + +def get_dynamic_global_limit(): + """ + Return different global limits based on authentication status. + Authenticated users get higher limits. + """ + if is_authenticated(): + return f"{RATE_LIMIT_GLOBAL_AUTH_HOURLY} per hour" + return f"{RATE_LIMIT_GLOBAL_HOURLY} per hour" + + +def rate_limit_error_handler(e): + """ + Custom error handler for rate limit exceeded (429) responses. + + Returns a JSON response with rate limit details and standard format + matching ResCanvas API error conventions. + """ + # Extract rate limit info from the limiter exception + limit_info = { + "status": "error", + "error": "rate_limit_exceeded", + "message": str(e.description) or "Rate limit exceeded. Please try again later.", + } + + # Add rate limit headers to help clients + response = jsonify(limit_info) + response.status_code = 429 + + # Add standard rate limit headers + # These are automatically added by Flask-Limiter, but we ensure they're present + if hasattr(e, 'limit'): + response.headers['X-RateLimit-Limit'] = str(e.limit.amount) + if hasattr(e, 'remaining'): + response.headers['X-RateLimit-Remaining'] = str(e.remaining) + if hasattr(e, 'reset_at'): + response.headers['X-RateLimit-Reset'] = str(int(e.reset_at)) + # Calculate seconds until reset for Retry-After header + now = datetime.now(timezone.utc).timestamp() + retry_after = max(1, int(e.reset_at - now)) + response.headers['Retry-After'] = str(retry_after) + + # Log rate limit violations for monitoring + user_id = get_authenticated_user_id() + ip_address = get_remote_address() + logger.warning( + f"Rate limit exceeded: user_id={user_id}, ip={ip_address}, " + f"endpoint={request.endpoint}, method={request.method}" + ) + + return response + + +def init_limiter(app): + """ + Initialize the Flask-Limiter with the Flask app. + + This should be called from app.py after the app is created. + """ + global limiter + + if not RATE_LIMIT_ENABLED: + logger.info("Rate limiting is DISABLED via configuration") + # Create a no-op limiter that doesn't enforce limits + limiter = Limiter( + app=app, + key_func=get_user_identifier, + enabled=False + ) + return limiter + + limiter = Limiter( + app=app, + key_func=get_user_identifier, + storage_uri=RATE_LIMIT_STORAGE_URI, + storage_options={ + "socket_connect_timeout": 5, + "socket_timeout": 5, + }, + # Default limits applied to all routes (can be overridden) + default_limits=[get_dynamic_global_limit], + # Add rate limit headers to all responses + headers_enabled=True, + # Retry-After header for 429 responses + retry_after="http-date", + # Custom error handler + on_breach=rate_limit_error_handler, + # Swallow errors (don't break app if Redis is down) + swallow_errors=True, + ) + + logger.info(f"Rate limiting ENABLED: storage={RATE_LIMIT_STORAGE_URI}") + return limiter + + +def room_specific_limit(limit_value): + """ + Decorator for room-specific rate limits. + + Example: @room_specific_limit("5/minute") + This limits operations to 5 per minute per room (not per user). + """ + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + # Extract roomId from URL params or request body + room_id = kwargs.get('roomId') or request.view_args.get('roomId') + if not room_id: + data = request.get_json(silent=True) or {} + room_id = data.get('roomId') + + if room_id: + # Use room-specific key for limit + key = f"room:{room_id}" + # Apply limit using the limiter + limiter.limit(limit_value, key_func=lambda: key)(f)(*args, **kwargs) + else: + # Fallback to default limit if no room identified + return f(*args, **kwargs) + return wrapper + return decorator + + +def exempt_from_limits(f): + """ + Decorator to exempt a route from rate limiting. + Use sparingly - only for health checks, static assets, etc. + """ + if limiter: + return limiter.exempt(f) + return f + + +# Pre-configured decorators for common use cases +def auth_rate_limit(limit_str): + """Apply rate limit to authentication endpoints.""" + def decorator(f): + if limiter: + return limiter.limit(limit_str, key_func=get_remote_address)(f) + return f + return decorator + + +def user_rate_limit(limit_str): + """Apply rate limit per authenticated user.""" + def decorator(f): + if limiter: + return limiter.limit(limit_str, key_func=get_user_identifier)(f) + return f + return decorator + + +def burst_protection(f): + """Apply burst protection (10 requests/second).""" + if limiter: + return limiter.limit("10/second", key_func=get_user_identifier)(f) + return f diff --git a/backend/requirements.txt b/backend/requirements.txt index bcd7deca..1a9ea527 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,6 +28,9 @@ httpcore==0.17.3 # Configuration & Environment python-dotenv==1.1.1 +# Rate Limiting +Flask-Limiter==3.5.0 + # ResilientDB Integration resilient-python-cache==0.1.1 diff --git a/backend/routes/auth.py b/backend/routes/auth.py index e0240db9..23d0d576 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -5,9 +5,14 @@ import jwt, re, os, hashlib, base64 from bson import ObjectId from services.db import users_coll, refresh_tokens_coll -from config import JWT_SECRET, JWT_ISSUER, ACCESS_TOKEN_EXPIRES_SECS, REFRESH_TOKEN_EXPIRES_SECS, REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_SECURE, REFRESH_TOKEN_COOKIE_SAMESITE +from config import ( + JWT_SECRET, JWT_ISSUER, ACCESS_TOKEN_EXPIRES_SECS, REFRESH_TOKEN_EXPIRES_SECS, + REFRESH_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_SECURE, REFRESH_TOKEN_COOKIE_SAMESITE, + RATE_LIMIT_LOGIN_HOURLY, RATE_LIMIT_REGISTER_HOURLY, RATE_LIMIT_REFRESH_HOURLY +) from middleware.auth import require_auth, validate_request_data from middleware.validators import validate_username, validate_password, validate_optional_string +from middleware.rate_limit import limiter, auth_rate_limit auth_bp = Blueprint("auth", __name__) @@ -51,6 +56,7 @@ def _find_valid_refresh_token(token_hash): return doc @auth_bp.route("/auth/register", methods=["POST"]) +@limiter.limit(f"{RATE_LIMIT_REGISTER_HOURLY}/hour") @validate_request_data({ "username": {"validator": validate_username, "required": True}, "password": {"validator": validate_password, "required": True}, @@ -94,6 +100,7 @@ def register(): return resp @auth_bp.route("/auth/login", methods=["POST"]) +@limiter.limit(f"{RATE_LIMIT_LOGIN_HOURLY}/hour") @validate_request_data({ "username": {"validator": validate_username, "required": True}, "password": {"validator": validate_password, "required": True} @@ -103,6 +110,7 @@ def login(): Login with username and password. Server-side enforcement: + - Rate limiting (100 attempts/hour per IP) - Input validation via @validate_request_data - Password verification with bcrypt - JWT access token generation @@ -126,6 +134,7 @@ def login(): return resp @auth_bp.route("/auth/refresh", methods=["POST"]) +@limiter.limit(f"{RATE_LIMIT_REFRESH_HOURLY}/hour") def refresh(): raw = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME) if not raw: diff --git a/backend/routes/clear_canvas.py b/backend/routes/clear_canvas.py index 2a2aa1fe..25d52f64 100644 --- a/backend/routes/clear_canvas.py +++ b/backend/routes/clear_canvas.py @@ -4,10 +4,11 @@ from services.graphql_service import commit_transaction_via_graphql from services.db import redis_client, strokes_coll from config import * +from middleware.rate_limit import limiter import logging import time import json -from config import SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY +from config import SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY, RATE_LIMIT_ROOM_CLEAR_MINUTE logger = logging.getLogger(__name__) @@ -45,6 +46,7 @@ def _persist_marker(id_value: str, value_field: str, value): logger.exception("Failed to persist marker %s", id_value) @clear_canvas_bp.route('/submitClearCanvasTimestamp', methods=['POST']) +@limiter.limit(f"{RATE_LIMIT_ROOM_CLEAR_MINUTE}/minute", key_func=lambda: request.get_json(silent=True).get('roomId', 'unknown') if request.is_json else 'unknown') def submit_clear_canvas_timestamp(): try: if not request.is_json: diff --git a/backend/routes/rooms.py b/backend/routes/rooms.py index 8ccc582f..5ca2adf8 100644 --- a/backend/routes/rooms.py +++ b/backend/routes/rooms.py @@ -10,9 +10,12 @@ from services.crypto_service import wrap_room_key, unwrap_room_key, encrypt_for_room, decrypt_for_room from services.graphql_service import commit_transaction_via_graphql, GraphQLService import os -from config import SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY +from config import ( + SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY, JWT_SECRET, + RATE_LIMIT_ROOM_CREATE_HOURLY, RATE_LIMIT_ROOM_UPDATE_MINUTE, + RATE_LIMIT_SEARCH_MINUTE, RATE_LIMIT_STROKE_MINUTE, RATE_LIMIT_UNDO_REDO_MINUTE +) import jwt -from config import JWT_SECRET from middleware.auth import require_auth, require_auth_optional, require_room_access, require_room_owner, validate_request_data from middleware.validators import ( validate_room_name, @@ -27,6 +30,7 @@ validate_member_id, validate_username ) +from middleware.rate_limit import limiter, user_rate_limit try: from routes.get_canvas_data import get_strokes_from_mongo except Exception: @@ -104,6 +108,7 @@ def _notification_allowed_for(user_identifier, ntype: str): @rooms_bp.route("/rooms", methods=["POST"]) @require_auth +@limiter.limit(f"{RATE_LIMIT_ROOM_CREATE_HOURLY}/hour") @validate_request_data({ 'name': validate_room_name, 'type': validate_room_type, @@ -603,6 +608,7 @@ def admin_fill_wrapped_key(roomId): @rooms_bp.route("/rooms//strokes", methods=["POST"]) @require_auth @require_room_access(room_id_param="roomId") +@limiter.limit(f"{RATE_LIMIT_STROKE_MINUTE}/minute") @validate_request_data({ "stroke": {"validator": lambda v: (isinstance(v, dict), "Stroke must be an object") if not isinstance(v, dict) else (True, None), "required": True}, "signature": {"validator": validate_optional_string(max_length=1000), "required": False}, @@ -1094,6 +1100,7 @@ def get_strokes(roomId): @rooms_bp.route("/rooms//undo", methods=["POST"]) @require_auth @require_room_access(room_id_param="roomId") +@limiter.limit(f"{RATE_LIMIT_UNDO_REDO_MINUTE}/minute") def room_undo(roomId): """ Undo the last action in a room. @@ -1235,6 +1242,7 @@ def get_undo_redo_status(roomId): @rooms_bp.route("/rooms//redo", methods=["POST"]) @require_auth @require_room_access(room_id_param="roomId") +@limiter.limit(f"{RATE_LIMIT_UNDO_REDO_MINUTE}/minute") def room_redo(roomId): """ Redo the last undone action in a room. diff --git a/backend/routes/submit_room_line.py b/backend/routes/submit_room_line.py index 265b4912..c49c1df0 100644 --- a/backend/routes/submit_room_line.py +++ b/backend/routes/submit_room_line.py @@ -9,13 +9,15 @@ from services.canvas_counter import get_canvas_draw_count, increment_canvas_draw_count from services.crypto_service import unwrap_room_key, encrypt_for_room, wrap_room_key import nacl.signing, nacl.encoding -from config import SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY, JWT_SECRET +from config import SIGNER_PUBLIC_KEY, SIGNER_PRIVATE_KEY, RECIPIENT_PUBLIC_KEY, JWT_SECRET, RATE_LIMIT_STROKE_MINUTE from cryptography.exceptions import InvalidTag +from middleware.rate_limit import limiter, user_rate_limit logger = logging.getLogger(__name__) submit_room_line_bp = Blueprint('submit_room_line', __name__) @submit_room_line_bp.route('/submitNewLineRoom', methods=['POST']) +@limiter.limit(f"{RATE_LIMIT_STROKE_MINUTE}/minute") def submit_room_line(): try: data = request.get_json(force=True) or {} diff --git a/backend/routes/undo_redo.py b/backend/routes/undo_redo.py index 4c867b49..fd7426e2 100644 --- a/backend/routes/undo_redo.py +++ b/backend/routes/undo_redo.py @@ -7,6 +7,7 @@ from services.db import redis_client from services.graphql_service import commit_transaction_via_graphql from config import * +from middleware.rate_limit import limiter, user_rate_limit logger = logging.getLogger(__name__) @@ -68,6 +69,7 @@ def _safe_get_stroke_id(stroke_obj): return None @undo_redo_bp.route('/undo', methods=['POST']) +@limiter.limit(f"{RATE_LIMIT_UNDO_REDO_MINUTE}/minute") def undo(): try: data = request.get_json(silent=True) or {} @@ -121,6 +123,7 @@ def undo(): return jsonify({"status": "error", "message": str(e)}), 500 @undo_redo_bp.route('/redo', methods=['POST']) +@limiter.limit(f"{RATE_LIMIT_UNDO_REDO_MINUTE}/minute") def redo(): try: data = request.get_json(silent=True) or {} diff --git a/backend/tests/test_rate_limiting.py b/backend/tests/test_rate_limiting.py new file mode 100644 index 00000000..696afebf --- /dev/null +++ b/backend/tests/test_rate_limiting.py @@ -0,0 +1,333 @@ +# backend/tests/test_rate_limiting.py +""" +Comprehensive tests for API rate limiting. + +Tests cover: +- Global rate limits (per IP) +- Endpoint-specific limits +- Authentication endpoint limits +- Stroke submission limits +- Room operation limits +- Rate limit headers +- 429 error responses +- Authenticated vs anonymous limits +""" + +import pytest +import time +import json +from datetime import datetime, timezone +from flask import Flask +from middleware.rate_limit import init_limiter, limiter +from services.db import redis_client + + +@pytest.fixture +def rate_limited_app(app): + """Create app with rate limiting enabled for testing.""" + # Initialize rate limiter + app.config['RATE_LIMIT_ENABLED'] = True + app.config['RATE_LIMIT_STORAGE'] = 'redis://localhost:6379' + init_limiter(app) + return app + + +@pytest.fixture +def cleanup_rate_limits(): + """Clean up rate limit counters after each test.""" + yield + # Clean up Redis keys used for rate limiting + try: + for key in redis_client.scan_iter("LIMITER/*"): + redis_client.delete(key) + except Exception: + pass + + +@pytest.mark.unit +@pytest.mark.auth +class TestAuthenticationRateLimits: + """Test rate limits on authentication endpoints.""" + + def test_login_rate_limit(self, client, cleanup_rate_limits): + """Test login endpoint has rate limit of 100/hour.""" + # Make requests until hitting limit + for i in range(105): + response = client.post('/auth/login', json={ + 'username': f'user{i}', + 'password': 'password' + }) + + if i < 100: + # Should accept first 100 requests (even if auth fails) + assert response.status_code in [401, 400] # Auth failure is OK + else: + # Should block after 100 requests + assert response.status_code == 429 + data = response.get_json() + assert data['error'] == 'rate_limit_exceeded' + assert 'X-RateLimit-Limit' in response.headers + assert 'Retry-After' in response.headers + break + + def test_register_rate_limit(self, client, cleanup_rate_limits): + """Test register endpoint has rate limit of 50/hour.""" + for i in range(55): + response = client.post('/auth/register', json={ + 'username': f'newuser{i}_{int(time.time())}', + 'password': 'password123' + }) + + if i < 50: + # Should accept first 50 requests + assert response.status_code in [201, 409, 400] + else: + # Should block after 50 requests + assert response.status_code == 429 + data = response.get_json() + assert 'rate_limit_exceeded' in data['error'] + break + + def test_refresh_rate_limit(self, client, cleanup_rate_limits): + """Test refresh endpoint has rate limit of 200/hour.""" + # Refresh tokens don't need to be valid to test rate limiting + for i in range(205): + response = client.post('/auth/refresh') + + if i < 200: + # Should accept first 200 requests (even if refresh fails) + assert response.status_code in [401, 400] + else: + # Should block after 200 requests + assert response.status_code == 429 + break + + +@pytest.mark.unit +@pytest.mark.stroke +class TestStrokeRateLimits: + """Test rate limits on stroke submission endpoints.""" + + def test_stroke_submission_rate_limit(self, client, auth_token, test_room, cleanup_rate_limits): + """Test stroke submission limited to 300/minute.""" + room_id = test_room['id'] + + # Rapidly submit strokes + rate_limited = False + for i in range(310): + response = client.post( + '/submitNewLineRoom', + headers={'Authorization': f'Bearer {auth_token}'}, + json={ + 'roomId': room_id, + 'value': {'pathData': [[i, i]], 'color': '#000000'} + } + ) + + if response.status_code == 429: + rate_limited = True + data = response.get_json() + assert data['error'] == 'rate_limit_exceeded' + assert int(response.headers['X-RateLimit-Limit']) == 300 + break + + assert rate_limited, "Should hit rate limit before 310 requests" + + def test_undo_redo_rate_limit(self, client, auth_token, test_room, cleanup_rate_limits): + """Test undo/redo operations limited to 60/minute.""" + room_id = test_room['id'] + + # Rapidly undo + for i in range(65): + response = client.post( + f'/rooms/{room_id}/undo', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'userId': 'testuser'} + ) + + if i < 60: + assert response.status_code in [200, 400, 404] # May fail but not rate limited + else: + assert response.status_code == 429 + break + + +@pytest.mark.unit +@pytest.mark.room +class TestRoomOperationRateLimits: + """Test rate limits on room management operations.""" + + def test_room_creation_rate_limit(self, client, auth_token, cleanup_rate_limits): + """Test room creation limited to 10/hour.""" + for i in range(12): + response = client.post( + '/rooms', + headers={'Authorization': f'Bearer {auth_token}'}, + json={ + 'name': f'Test Room {i}_{int(time.time())}', + 'type': 'public' + } + ) + + if i < 10: + assert response.status_code == 201 + else: + assert response.status_code == 429 + data = response.get_json() + assert 'rate_limit_exceeded' in data['error'] + break + + def test_room_clear_rate_limit(self, client, test_room, cleanup_rate_limits): + """Test canvas clear limited to 5/minute per room.""" + room_id = test_room['id'] + + for i in range(7): + response = client.post( + '/submitClearCanvasTimestamp', + json={'roomId': room_id, 'ts': int(time.time() * 1000)} + ) + + if i < 5: + assert response.status_code in [200, 400] + else: + assert response.status_code == 429 + break + + def test_search_rate_limit(self, client, auth_token, cleanup_rate_limits): + """Test search endpoints limited to 30/minute.""" + for i in range(35): + response = client.get( + '/users/suggest?q=test', + headers={'Authorization': f'Bearer {auth_token}'} + ) + + if i < 30: + assert response.status_code == 200 + else: + assert response.status_code == 429 + break + + +@pytest.mark.integration +class TestRateLimitHeaders: + """Test that rate limit headers are properly set.""" + + def test_rate_limit_headers_present(self, client, auth_token): + """Test that rate limit headers are included in responses.""" + response = client.get('/rooms', headers={'Authorization': f'Bearer {auth_token}'}) + + # Headers should be present (even on success) + assert 'X-RateLimit-Limit' in response.headers + assert 'X-RateLimit-Remaining' in response.headers + assert int(response.headers['X-RateLimit-Remaining']) >= 0 + + def test_rate_limit_429_response_format(self, client, cleanup_rate_limits): + """Test that 429 responses have correct format.""" + # Trigger rate limit on login + for _ in range(105): + response = client.post('/auth/login', json={ + 'username': 'test', 'password': 'test' + }) + + assert response.status_code == 429 + data = response.get_json() + + # Check error response format + assert data['status'] == 'error' + assert data['error'] == 'rate_limit_exceeded' + assert 'message' in data + + # Check headers + assert 'X-RateLimit-Limit' in response.headers + assert 'X-RateLimit-Remaining' in response.headers + assert 'X-RateLimit-Reset' in response.headers + assert 'Retry-After' in response.headers + + # Verify Retry-After is reasonable + retry_after = int(response.headers['Retry-After']) + assert 0 < retry_after <= 3600 # Should be within an hour + + +@pytest.mark.integration +class TestAuthenticatedVsAnonymousLimits: + """Test different rate limits for authenticated vs anonymous users.""" + + def test_authenticated_users_higher_global_limit(self, client, auth_token, cleanup_rate_limits): + """Authenticated users should have higher global limits.""" + # This is more of a configuration test + # In production, authenticated users get 5000/hour vs 1000/hour for anonymous + + # Make authenticated request + response = client.get('/rooms', headers={'Authorization': f'Bearer {auth_token}'}) + + # Check that limit is higher for authenticated users + limit = int(response.headers.get('X-RateLimit-Limit', 0)) + assert limit > 1000 # Should be 5000 for authenticated + + def test_anonymous_requests_lower_limit(self, client, cleanup_rate_limits): + """Anonymous users should have lower global limits.""" + # Make request without auth + response = client.get('/rooms') + + # Should have lower limit + if 'X-RateLimit-Limit' in response.headers: + limit = int(response.headers['X-RateLimit-Limit']) + assert limit <= 1000 # Anonymous limit + + +@pytest.mark.integration +class TestRateLimitCORSCompatibility: + """Test that rate limit errors include proper CORS headers.""" + + def test_429_includes_cors_headers(self, client, cleanup_rate_limits): + """Test that 429 responses include CORS headers.""" + # Trigger rate limit + for _ in range(105): + response = client.post('/auth/login', + headers={'Origin': 'http://localhost:3000'}, + json={'username': 'test', 'password': 'test'} + ) + + assert response.status_code == 429 + + # CORS headers should be present + assert 'Access-Control-Allow-Origin' in response.headers + assert 'Access-Control-Allow-Credentials' in response.headers + + +@pytest.mark.integration +class TestRateLimitRecovery: + """Test that rate limits reset properly.""" + + @pytest.mark.slow + def test_rate_limit_resets_after_window(self, client, cleanup_rate_limits): + """Test that rate limits reset after time window.""" + # This test would need to wait for actual time to pass + # In a real scenario, you'd use time mocking or shorter windows for testing + pytest.skip("Requires time manipulation or long wait") + + +@pytest.mark.unit +class TestRateLimitConfiguration: + """Test rate limit configuration and environment variables.""" + + def test_rate_limiting_can_be_disabled(self, app): + """Test that rate limiting can be disabled via config.""" + app.config['RATE_LIMIT_ENABLED'] = False + test_limiter = init_limiter(app) + + assert test_limiter is not None + # When disabled, limiter should be created but not enforced + + def test_custom_rate_limits_from_env(self, app, monkeypatch): + """Test that rate limits can be customized via environment variables.""" + monkeypatch.setenv('RATE_LIMIT_LOGIN_HOURLY', '50') + monkeypatch.setenv('RATE_LIMIT_STROKE_MINUTE', '100') + + # Re-import config to pick up new values + from importlib import reload + import config + reload(config) + + assert config.RATE_LIMIT_LOGIN_HOURLY == 50 + assert config.RATE_LIMIT_STROKE_MINUTE == 100 diff --git a/frontend/src/api/apiClient.js b/frontend/src/api/apiClient.js new file mode 100644 index 00000000..9405d577 --- /dev/null +++ b/frontend/src/api/apiClient.js @@ -0,0 +1,190 @@ +// frontend/src/api/apiClient.js +/** + * Enhanced API Client with Rate Limit Handling + * + * This wrapper provides: + * - Automatic rate limit detection and retry + * - Request queueing during rate limits + * - Rate limit monitoring and warnings + * - Consistent error handling + */ + +import { + rateLimitAwareFetch, + parseRateLimitInfo, + formatRateLimitMessage, + isRateLimitError, + showRateLimitNotification, + globalRateLimitMonitor, +} from '../utils/rateLimitHandler'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:10010'; + +/** + * Get auth token from localStorage + */ +function getAuthToken() { + return localStorage.getItem('token'); +} + +/** + * Build headers for API request + */ +function buildHeaders(customHeaders = {}) { + const headers = { + 'Content-Type': 'application/json', + ...customHeaders, + }; + + const token = getAuthToken(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; +} + +/** + * Handle API response + */ +async function handleResponse(response) { + // Monitor rate limit headers + globalRateLimitMonitor.checkResponse(response, response.url, (info) => { + console.warn('Approaching rate limit:', info); + // You can add a toast notification here + }); + + if (!response.ok) { + if (response.status === 429) { + const error = new Error('Rate limit exceeded'); + error.response = response; + error.rateLimitInfo = parseRateLimitInfo(response); + throw error; + } + + // Try to parse error message from JSON + try { + const errorData = await response.json(); + const error = new Error(errorData.message || 'API request failed'); + error.status = response.status; + error.data = errorData; + throw error; + } catch (parseError) { + const error = new Error(`HTTP ${response.status}: ${response.statusText}`); + error.status = response.status; + throw error; + } + } + + return await response.json(); +} + +/** + * Make API request with rate limit handling + */ +async function apiRequest(endpoint, options = {}, retryOptions = {}) { + const url = `${API_BASE}${endpoint}`; + const headers = buildHeaders(options.headers); + + const fetchOptions = { + ...options, + headers, + }; + + try { + const response = await rateLimitAwareFetch(url, fetchOptions, { + maxAttempts: retryOptions.maxAttempts || 3, + onRetry: (attempt, delay, rateLimitInfo) => { + console.log(`Rate limited. Retrying attempt ${attempt} after ${delay}ms`); + // You can add a toast notification here + }, + }); + + return await handleResponse(response); + } catch (error) { + if (isRateLimitError(error)) { + // Show user-friendly rate limit message + const message = formatRateLimitMessage(error); + console.error('Rate limit exceeded:', message); + // You can add a toast notification here + } + throw error; + } +} + +/** + * API Client object with all methods + */ +const apiClient = { + // GET request + get: (endpoint, options = {}) => { + return apiRequest(endpoint, { + method: 'GET', + ...options, + }); + }, + + // POST request + post: (endpoint, data, options = {}) => { + return apiRequest(endpoint, { + method: 'POST', + body: JSON.stringify(data), + ...options, + }); + }, + + // PUT request + put: (endpoint, data, options = {}) => { + return apiRequest(endpoint, { + method: 'PUT', + body: JSON.stringify(data), + ...options, + }); + }, + + // PATCH request + patch: (endpoint, data, options = {}) => { + return apiRequest(endpoint, { + method: 'PATCH', + body: JSON.stringify(data), + ...options, + }); + }, + + // DELETE request + delete: (endpoint, options = {}) => { + return apiRequest(endpoint, { + method: 'DELETE', + ...options, + }); + }, +}; + +export default apiClient; + +/** + * Example Usage: + * + * import apiClient from './api/apiClient'; + * + * // Simple GET request + * const rooms = await apiClient.get('/rooms'); + * + * // POST with data + * const newRoom = await apiClient.post('/rooms', { + * name: 'My Room', + * type: 'public' + * }); + * + * // Handle errors + * try { + * await apiClient.post('/rooms//strokes', strokeData); + * } catch (error) { + * if (error.status === 429) { + * // Rate limited - already handled automatically + * console.log('Rate limited, but will retry automatically'); + * } else { + * console.error('API error:', error.message); + * } + * } + */ diff --git a/frontend/src/components/RateLimitWarning.css b/frontend/src/components/RateLimitWarning.css new file mode 100644 index 00000000..68e39181 --- /dev/null +++ b/frontend/src/components/RateLimitWarning.css @@ -0,0 +1,115 @@ +/* frontend/src/components/RateLimitWarning.css */ + +.rate-limit-warning { + display: flex; + align-items: flex-start; + padding: 16px; + margin: 16px 0; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.rate-limit-warning.warning { + background-color: #fff3cd; + border-left: 4px solid #ffc107; + color: #856404; +} + +.rate-limit-warning.exceeded { + background-color: #f8d7da; + border-left: 4px solid #dc3545; + color: #721c24; +} + +.rate-limit-icon { + font-size: 24px; + margin-right: 12px; + flex-shrink: 0; +} + +.rate-limit-content { + flex: 1; +} + +.rate-limit-title { + font-weight: bold; + font-size: 16px; + margin-bottom: 8px; +} + +.rate-limit-message { + font-size: 14px; + line-height: 1.5; +} + +.rate-limit-message p { + margin: 0; +} + +.rate-limit-details { + font-size: 12px; + opacity: 0.8; + margin-top: 8px; + font-family: monospace; +} + +.rate-limit-dismiss { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + margin-left: 12px; + opacity: 0.6; + transition: opacity 0.2s; +} + +.rate-limit-dismiss:hover { + opacity: 1; +} + +.rate-limit-warning.warning .rate-limit-dismiss { + color: #856404; +} + +.rate-limit-warning.exceeded .rate-limit-dismiss { + color: #721c24; +} + +/* Responsive design */ +@media (max-width: 768px) { + .rate-limit-warning { + padding: 12px; + margin: 12px 0; + } + + .rate-limit-icon { + font-size: 20px; + margin-right: 8px; + } + + .rate-limit-title { + font-size: 14px; + } + + .rate-limit-message { + font-size: 13px; + } +} diff --git a/frontend/src/components/RateLimitWarning.js b/frontend/src/components/RateLimitWarning.js new file mode 100644 index 00000000..1b122b72 --- /dev/null +++ b/frontend/src/components/RateLimitWarning.js @@ -0,0 +1,131 @@ +// frontend/src/components/RateLimitWarning.js +/** + * Rate Limit Warning Component + * + * Displays user-friendly warnings when rate limits are approached or exceeded + */ + +import React, { useState, useEffect } from 'react'; +import './RateLimitWarning.css'; + +const RateLimitWarning = ({ rateLimitInfo, onDismiss }) => { + const [timeRemaining, setTimeRemaining] = useState(null); + + useEffect(() => { + if (!rateLimitInfo || !rateLimitInfo.reset) { + return; + } + + // Calculate initial time remaining + const calculateTimeRemaining = () => { + const now = Math.floor(Date.now() / 1000); + const remaining = rateLimitInfo.reset - now; + return Math.max(0, remaining); + }; + + setTimeRemaining(calculateTimeRemaining()); + + // Update every second + const interval = setInterval(() => { + const remaining = calculateTimeRemaining(); + setTimeRemaining(remaining); + + if (remaining <= 0) { + clearInterval(interval); + if (onDismiss) { + onDismiss(); + } + } + }, 1000); + + return () => clearInterval(interval); + }, [rateLimitInfo, onDismiss]); + + if (!rateLimitInfo) { + return null; + } + + const formatTime = (seconds) => { + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? 's' : ''}`; + } + const minutes = Math.ceil(seconds / 60); + return `${minutes} minute${minutes !== 1 ? 's' : ''}`; + }; + + const isExceeded = rateLimitInfo.remaining === 0 || rateLimitInfo.exceeded; + const isWarning = !isExceeded && rateLimitInfo.remaining <= rateLimitInfo.limit * 0.2; + + return ( +
+
+ {isExceeded ? '🚫' : '⚠️'} +
+
+
+ {isExceeded ? 'Rate Limit Exceeded' : 'Approaching Rate Limit'} +
+
+ {isExceeded ? ( +

+ You've reached the maximum number of requests allowed. + {timeRemaining !== null && timeRemaining > 0 && ( + <> Please wait {formatTime(timeRemaining)} before trying again. + )} +

+ ) : ( +

+ You have {rateLimitInfo.remaining} of {rateLimitInfo.limit} requests remaining. + Please slow down to avoid being temporarily blocked. +

+ )} +
+ {rateLimitInfo.endpoint && ( +
+ Endpoint: {rateLimitInfo.endpoint} +
+ )} +
+ {onDismiss && !isExceeded && ( + + )} +
+ ); +}; + +export default RateLimitWarning; + +/** + * Example Usage: + * + * import RateLimitWarning from './components/RateLimitWarning'; + * + * function MyComponent() { + * const [rateLimitInfo, setRateLimitInfo] = useState(null); + * + * // When you catch a rate limit error: + * catch (error) { + * if (error.status === 429) { + * setRateLimitInfo({ + * exceeded: true, + * limit: error.rateLimitInfo?.limit, + * remaining: 0, + * reset: error.rateLimitInfo?.reset, + * }); + * } + * } + * + * return ( + *
+ * {rateLimitInfo && ( + * setRateLimitInfo(null)} + * /> + * )} + *
+ * ); + * } + */ diff --git a/frontend/src/utils/rateLimitHandler.js b/frontend/src/utils/rateLimitHandler.js new file mode 100644 index 00000000..468a15f2 --- /dev/null +++ b/frontend/src/utils/rateLimitHandler.js @@ -0,0 +1,306 @@ +// frontend/src/utils/rateLimitHandler.js +/** + * Rate Limit Handler for ResCanvas Frontend + * + * Provides utilities for: + * - Detecting rate limit errors (429) + * - Parsing rate limit headers + * - Auto-retry with exponential backoff + * - User notifications + * - Request queueing during rate limits + */ + +/** + * Parse rate limit information from response headers + */ +export function parseRateLimitInfo(response) { + if (!response || !response.headers) { + return null; + } + + return { + limit: parseInt(response.headers.get('X-RateLimit-Limit')) || null, + remaining: parseInt(response.headers.get('X-RateLimit-Remaining')) || null, + reset: parseInt(response.headers.get('X-RateLimit-Reset')) || null, + retryAfter: parseInt(response.headers.get('Retry-After')) || null, + }; +} + +/** + * Check if a response is a rate limit error + */ +export function isRateLimitError(error) { + return ( + error && + error.response && + error.response.status === 429 + ); +} + +/** + * Calculate delay for exponential backoff + */ +export function calculateBackoffDelay(attempt, baseDelay = 1000, maxDelay = 60000) { + const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); + // Add jitter to prevent thundering herd + const jitter = Math.random() * delay * 0.1; + return delay + jitter; +} + +/** + * Sleep for specified milliseconds + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retry a function with exponential backoff + * + * @param {Function} fn - Async function to retry + * @param {Object} options - Retry options + * @returns {Promise} - Result of successful function call + */ +export async function retryWithBackoff(fn, options = {}) { + const { + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 60000, + onRetry = null, + } = options; + + let lastError; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + // Don't retry if it's not a rate limit error + if (!isRateLimitError(error)) { + throw error; + } + + // Don't retry on last attempt + if (attempt === maxAttempts - 1) { + break; + } + + // Calculate delay + const rateLimitInfo = parseRateLimitInfo(error.response); + let delay; + + if (rateLimitInfo && rateLimitInfo.retryAfter) { + // Use server-provided Retry-After header (in seconds) + delay = rateLimitInfo.retryAfter * 1000; + } else { + // Use exponential backoff + delay = calculateBackoffDelay(attempt, baseDelay, maxDelay); + } + + // Notify about retry + if (onRetry) { + onRetry(attempt + 1, delay, rateLimitInfo); + } + + // Wait before retrying + await sleep(delay); + } + } + + // All retries exhausted + throw lastError; +} + +/** + * Format user-friendly rate limit message + */ +export function formatRateLimitMessage(error) { + const rateLimitInfo = parseRateLimitInfo(error.response); + + if (rateLimitInfo && rateLimitInfo.retryAfter) { + const seconds = rateLimitInfo.retryAfter; + if (seconds < 60) { + return `Rate limit exceeded. Please wait ${seconds} seconds.`; + } else { + const minutes = Math.ceil(seconds / 60); + return `Rate limit exceeded. Please wait ${minutes} minute(s).`; + } + } + + return 'Rate limit exceeded. Please try again in a moment.'; +} + +/** + * Request Queue for handling rate limits + * Queues requests when rate limited and executes them after limit resets + */ +export class RequestQueue { + constructor() { + this.queue = []; + this.processing = false; + this.rateLimitInfo = null; + } + + /** + * Add request to queue + */ + enqueue(requestFn, resolve, reject) { + this.queue.push({ requestFn, resolve, reject }); + + if (!this.processing) { + this.processQueue(); + } + } + + /** + * Process queued requests + */ + async processQueue() { + if (this.processing || this.queue.length === 0) { + return; + } + + this.processing = true; + + while (this.queue.length > 0) { + const { requestFn, resolve, reject } = this.queue[0]; + + try { + // If we have rate limit info, wait before executing + if (this.rateLimitInfo && this.rateLimitInfo.retryAfter) { + await sleep(this.rateLimitInfo.retryAfter * 1000); + this.rateLimitInfo = null; + } + + const result = await requestFn(); + resolve(result); + this.queue.shift(); + } catch (error) { + if (isRateLimitError(error)) { + // Update rate limit info and wait + this.rateLimitInfo = parseRateLimitInfo(error.response); + + // Wait before processing next request + if (this.rateLimitInfo && this.rateLimitInfo.retryAfter) { + await sleep(this.rateLimitInfo.retryAfter * 1000); + this.rateLimitInfo = null; + } else { + // Fallback delay + await sleep(60000); // 1 minute + } + } else { + // Not a rate limit error, reject and remove from queue + reject(error); + this.queue.shift(); + } + } + } + + this.processing = false; + } + + /** + * Wrap a request function with queueing + */ + wrap(requestFn) { + return () => { + return new Promise((resolve, reject) => { + this.enqueue(requestFn, resolve, reject); + }); + }; + } +} + +/** + * Global request queue instance + */ +export const globalRequestQueue = new RequestQueue(); + +/** + * Rate limit aware fetch wrapper + */ +export async function rateLimitAwareFetch(url, options = {}, retryOptions = {}) { + const fetchFn = async () => { + const response = await fetch(url, options); + + if (response.status === 429) { + const error = new Error('Rate limit exceeded'); + error.response = response; + error.rateLimitInfo = parseRateLimitInfo(response); + throw error; + } + + return response; + }; + + return await retryWithBackoff(fetchFn, { + maxAttempts: retryOptions.maxAttempts || 3, + baseDelay: retryOptions.baseDelay || 1000, + onRetry: retryOptions.onRetry, + }); +} + +/** + * Show rate limit notification to user + */ +export function showRateLimitNotification(error, notificationFn) { + const message = formatRateLimitMessage(error); + const rateLimitInfo = parseRateLimitInfo(error.response); + + if (notificationFn) { + notificationFn({ + type: 'warning', + message: message, + duration: rateLimitInfo?.retryAfter ? rateLimitInfo.retryAfter * 1000 : 5000, + }); + } else { + // Fallback to console + console.warn('Rate limit exceeded:', message); + } +} + +/** + * Monitor rate limit headers and warn user when approaching limit + */ +export class RateLimitMonitor { + constructor(warningThreshold = 0.2) { + this.warningThreshold = warningThreshold; // Warn when 20% remaining + this.lastWarningTime = {}; + } + + /** + * Check response headers and warn if approaching limit + */ + checkResponse(response, endpoint, warningCallback) { + const rateLimitInfo = parseRateLimitInfo(response); + + if (!rateLimitInfo || rateLimitInfo.limit === null || rateLimitInfo.remaining === null) { + return; + } + + const percentRemaining = rateLimitInfo.remaining / rateLimitInfo.limit; + + if (percentRemaining <= this.warningThreshold) { + // Only warn once per minute per endpoint + const now = Date.now(); + const lastWarning = this.lastWarningTime[endpoint] || 0; + + if (now - lastWarning > 60000) { + this.lastWarningTime[endpoint] = now; + + if (warningCallback) { + warningCallback({ + endpoint, + limit: rateLimitInfo.limit, + remaining: rateLimitInfo.remaining, + reset: rateLimitInfo.reset, + }); + } + } + } + } +} + +export const globalRateLimitMonitor = new RateLimitMonitor();