Coverage for src / local_deep_research / security / rate_limiter.py: 82%
37 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
1"""
2Rate limiting for API endpoints using Flask-Limiter.
4Provides decorators and configuration for rate limiting HTTP requests
5to prevent abuse and resource exhaustion.
7Configuration:
8 RATE_LIMIT_FAIL_CLOSED: Set to "true" in production to fail closed
9 when rate limiter is not initialized. Default is fail-open for
10 easier development setup.
12 For production, also configure:
13 storage_uri="redis://localhost:6379" for multi-worker support
14"""
16import os
18from flask import g
19from flask_limiter import Limiter
20from flask_limiter.util import get_remote_address
21from loguru import logger
24# Global limiter instance (initialized by app factory)
25_limiter = None
27# Configuration: fail-closed in production, fail-open in development
28RATE_LIMIT_FAIL_CLOSED = (
29 os.environ.get("RATE_LIMIT_FAIL_CLOSED", "false").lower() == "true"
30)
33def get_rate_limiter() -> Limiter:
34 """
35 Get the global rate limiter instance.
37 Returns:
38 Flask-Limiter instance
40 Raises:
41 RuntimeError: If limiter hasn't been initialized
42 """
43 global _limiter
44 if _limiter is None:
45 raise RuntimeError(
46 "Rate limiter not initialized. Call init_rate_limiter(app) first."
47 )
48 return _limiter
51def init_rate_limiter(app):
52 """
53 Initialize the rate limiter with the Flask app.
55 This should be called once during app initialization.
57 Args:
58 app: Flask application instance
60 Returns:
61 Configured Limiter instance
62 """
63 global _limiter
65 # Use authenticated username if available, otherwise fall back to IP
66 def get_user_identifier():
67 # Check if user is authenticated
68 username = g.get("current_user")
69 if username:
70 return f"user:{username}"
71 # Fall back to IP address for unauthenticated requests
72 return f"ip:{get_remote_address()}"
74 _limiter = Limiter(
75 app=app,
76 key_func=get_user_identifier,
77 default_limits=[], # No default limits - apply via decorators only
78 storage_uri="memory://", # Use in-memory storage (for development)
79 # For production, consider: storage_uri="redis://localhost:6379"
80 strategy="fixed-window",
81 headers_enabled=True, # Add rate limit headers to responses
82 )
84 logger.info("Rate limiter initialized successfully")
85 return _limiter
88def upload_rate_limit(f):
89 """
90 Decorator for rate limiting file upload endpoints.
92 Limits:
93 - 10 uploads per minute per user
94 - 100 uploads per hour per user
96 Usage:
97 @research_bp.route("/api/upload/pdf", methods=["POST"])
98 @login_required
99 @upload_rate_limit
100 def upload_pdf():
101 ...
103 Returns:
104 Decorated function with rate limiting
106 Note:
107 If the rate limiter is not initialized (e.g., flask_limiter not installed),
108 behavior depends on RATE_LIMIT_FAIL_CLOSED environment variable:
109 - "false" (default): Pass through without rate limiting (fail-open)
110 - "true": Raise RuntimeError, crashing the application (fail-closed)
112 Set RATE_LIMIT_FAIL_CLOSED=true in production to ensure rate limiting
113 is always active.
114 """
115 try:
116 limiter = get_rate_limiter()
117 # Use Flask-Limiter's limit decorator
118 return limiter.limit("10 per minute;100 per hour")(f)
119 except RuntimeError:
120 # Rate limiter not initialized - this is expected if flask_limiter
121 # is not installed or init_rate_limiter was not called
122 if RATE_LIMIT_FAIL_CLOSED:
123 logger.exception(
124 f"Rate limiter not initialized for {f.__name__} and "
125 "RATE_LIMIT_FAIL_CLOSED is enabled. Application will fail."
126 )
127 raise
128 logger.warning(
129 f"Rate limiting disabled for {f.__name__}: "
130 "limiter not initialized. Install flask_limiter for rate limiting. "
131 "Set RATE_LIMIT_FAIL_CLOSED=true to enforce rate limiting in production."
132 )
133 return f
134 except Exception:
135 # Unexpected error - log it but don't break the application
136 if RATE_LIMIT_FAIL_CLOSED:
137 logger.exception(
138 f"Unexpected error applying rate limit to {f.__name__} and "
139 "RATE_LIMIT_FAIL_CLOSED is enabled. Application will fail."
140 )
141 raise
142 logger.exception(
143 f"Unexpected error applying rate limit to {f.__name__}. "
144 "Rate limiting disabled for this endpoint. "
145 "Set RATE_LIMIT_FAIL_CLOSED=true to enforce rate limiting in production."
146 )
147 return f
150# Export convenience decorator
151__all__ = ["init_rate_limiter", "get_rate_limiter", "upload_rate_limit"]