rateforge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rateforge/__init__.py +32 -0
- rateforge/health/__init__.py +5 -0
- rateforge/health/checks.py +286 -0
- rateforge/integrations/__init__.py +35 -0
- rateforge/integrations/django.py +340 -0
- rateforge/integrations/fastapi.py +369 -0
- rateforge/logging_config.py +93 -0
- rateforge/rate_limit/__init__.py +37 -0
- rateforge/rate_limit/algorithms.py +63 -0
- rateforge/rate_limit/backend.py +35 -0
- rateforge/rate_limit/decorator.py +299 -0
- rateforge/rate_limit/duration.py +58 -0
- rateforge/rate_limit/exceptions.py +76 -0
- rateforge/rate_limit/identity.py +94 -0
- rateforge/rate_limit/keys.py +10 -0
- rateforge/rate_limit/limiter.py +212 -0
- rateforge/rate_limit/models.py +27 -0
- rateforge/rate_limit/plans.py +18 -0
- rateforge/rate_limit/policy.py +31 -0
- rateforge/rate_limit/responses.py +76 -0
- rateforge/rate_limit/test_identity.py +47 -0
- rateforge-0.1.0.dist-info/METADATA +1142 -0
- rateforge-0.1.0.dist-info/RECORD +26 -0
- rateforge-0.1.0.dist-info/WHEEL +5 -0
- rateforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- rateforge-0.1.0.dist-info/top_level.txt +1 -0
rateforge/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from .rate_limit import (
|
|
2
|
+
RateLimiter,
|
|
3
|
+
RateLimitResult,
|
|
4
|
+
RateLimitContext,
|
|
5
|
+
RateLimitPolicy,
|
|
6
|
+
RateLimitExceeded,
|
|
7
|
+
get_default_limiter,
|
|
8
|
+
configure_limiter,
|
|
9
|
+
rate_limit,
|
|
10
|
+
)
|
|
11
|
+
from .logging_config import setup_logging
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# Core
|
|
17
|
+
"RateLimiter",
|
|
18
|
+
"RateLimitResult",
|
|
19
|
+
"RateLimitContext",
|
|
20
|
+
"RateLimitPolicy",
|
|
21
|
+
"RateLimitExceeded",
|
|
22
|
+
|
|
23
|
+
# Global limiter
|
|
24
|
+
"get_default_limiter",
|
|
25
|
+
"configure_limiter",
|
|
26
|
+
|
|
27
|
+
# Decorator
|
|
28
|
+
"rate_limit",
|
|
29
|
+
|
|
30
|
+
# Logging
|
|
31
|
+
"setup_logging",
|
|
32
|
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RateForge health module.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Five health checks (Redis, Database, Filesystem, Memory, Config)
|
|
6
|
+
- Health check results with status and latency
|
|
7
|
+
- Framework-agnostic health checker
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HealthCheckResult:
|
|
18
|
+
"""Result of a single health check."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
status: str # "healthy", "unhealthy", "degraded"
|
|
22
|
+
latency_ms: float | None = None
|
|
23
|
+
error: str | None = None
|
|
24
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
timestamp: float = field(default_factory=time.time)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
"""Convert to dictionary for JSON serialization."""
|
|
29
|
+
result = {
|
|
30
|
+
"status": self.status,
|
|
31
|
+
"timestamp": self.timestamp,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if self.latency_ms is not None:
|
|
35
|
+
result["latency_ms"] = self.latency_ms
|
|
36
|
+
|
|
37
|
+
if self.error:
|
|
38
|
+
result["error"] = self.error
|
|
39
|
+
|
|
40
|
+
if self.details:
|
|
41
|
+
result["details"] = self.details
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HealthChecker:
|
|
47
|
+
"""
|
|
48
|
+
Perform health checks for RateForge.
|
|
49
|
+
|
|
50
|
+
Five checks:
|
|
51
|
+
1. Redis connectivity and latency
|
|
52
|
+
2. Database (placeholder for future)
|
|
53
|
+
3. Filesystem write permissions
|
|
54
|
+
4. Memory usage
|
|
55
|
+
5. Configuration validity
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> checker = HealthChecker()
|
|
59
|
+
>>> results = checker.run_all_checks()
|
|
60
|
+
>>> print(results["redis"].status)
|
|
61
|
+
"healthy"
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def check_redis(self) -> HealthCheckResult:
|
|
65
|
+
"""
|
|
66
|
+
Check Redis connectivity and latency.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
HealthCheckResult with status and latency
|
|
70
|
+
"""
|
|
71
|
+
start = time.time()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from rateforge import get_default_limiter
|
|
75
|
+
|
|
76
|
+
limiter = get_default_limiter()
|
|
77
|
+
is_connected = limiter.backend.ping()
|
|
78
|
+
latency = (time.time() - start) * 1000
|
|
79
|
+
|
|
80
|
+
if is_connected:
|
|
81
|
+
return HealthCheckResult(
|
|
82
|
+
name="redis",
|
|
83
|
+
status="healthy",
|
|
84
|
+
latency_ms=round(latency, 2),
|
|
85
|
+
details={"message": "Redis connection successful"},
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
return HealthCheckResult(
|
|
89
|
+
name="redis",
|
|
90
|
+
status="unhealthy",
|
|
91
|
+
latency_ms=round(latency, 2),
|
|
92
|
+
error="Redis ping failed",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
latency = (time.time() - start) * 1000
|
|
97
|
+
return HealthCheckResult(
|
|
98
|
+
name="redis",
|
|
99
|
+
status="unhealthy",
|
|
100
|
+
latency_ms=round(latency, 2),
|
|
101
|
+
error=str(exc),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def check_database(self) -> HealthCheckResult:
|
|
105
|
+
"""
|
|
106
|
+
Check database connectivity (placeholder for future).
|
|
107
|
+
|
|
108
|
+
RateForge currently doesn't use a database, but this check
|
|
109
|
+
is included for future extensibility.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
HealthCheckResult with placeholder status
|
|
113
|
+
"""
|
|
114
|
+
return HealthCheckResult(
|
|
115
|
+
name="database",
|
|
116
|
+
status="healthy",
|
|
117
|
+
details={"note": "Database not configured in RateForge"},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def check_filesystem(self) -> HealthCheckResult:
|
|
121
|
+
"""
|
|
122
|
+
Check filesystem write permissions.
|
|
123
|
+
|
|
124
|
+
Tests ability to write to temporary directory.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
HealthCheckResult with status
|
|
128
|
+
"""
|
|
129
|
+
import tempfile
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Test write to temp directory
|
|
133
|
+
with tempfile.NamedTemporaryFile(delete=True) as f:
|
|
134
|
+
f.write(b"rateforge_health_check")
|
|
135
|
+
f.flush()
|
|
136
|
+
|
|
137
|
+
return HealthCheckResult(
|
|
138
|
+
name="filesystem",
|
|
139
|
+
status="healthy",
|
|
140
|
+
details={"message": "Filesystem write test successful"},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
return HealthCheckResult(
|
|
145
|
+
name="filesystem",
|
|
146
|
+
status="unhealthy",
|
|
147
|
+
error=f"Filesystem write failed: {exc}",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def check_memory(self) -> HealthCheckResult:
|
|
151
|
+
"""
|
|
152
|
+
Check memory usage.
|
|
153
|
+
|
|
154
|
+
Uses psutil if available, otherwise returns degraded status.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
HealthCheckResult with memory usage details
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
import psutil
|
|
161
|
+
|
|
162
|
+
memory = psutil.virtual_memory()
|
|
163
|
+
usage_percent = memory.percent
|
|
164
|
+
|
|
165
|
+
# Determine status based on usage
|
|
166
|
+
if usage_percent < 70:
|
|
167
|
+
status = "healthy"
|
|
168
|
+
elif usage_percent < 90:
|
|
169
|
+
status = "degraded"
|
|
170
|
+
else:
|
|
171
|
+
status = "unhealthy"
|
|
172
|
+
|
|
173
|
+
return HealthCheckResult(
|
|
174
|
+
name="memory",
|
|
175
|
+
status=status,
|
|
176
|
+
details={
|
|
177
|
+
"usage_percent": usage_percent,
|
|
178
|
+
"available_mb": round(memory.available / (1024 * 1024), 2),
|
|
179
|
+
"total_mb": round(memory.total / (1024 * 1024), 2),
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
except ImportError:
|
|
184
|
+
# psutil not installed
|
|
185
|
+
return HealthCheckResult(
|
|
186
|
+
name="memory",
|
|
187
|
+
status="degraded",
|
|
188
|
+
details={"note": "psutil not installed, cannot check memory"},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
return HealthCheckResult(
|
|
193
|
+
name="memory",
|
|
194
|
+
status="unhealthy",
|
|
195
|
+
error=str(exc),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def check_config(self) -> HealthCheckResult:
|
|
199
|
+
"""
|
|
200
|
+
Check configuration validity.
|
|
201
|
+
|
|
202
|
+
Validates environment variables and configuration.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
HealthCheckResult with configuration status
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
redis_url = os.getenv("RATEFORGE_REDIS_URL")
|
|
209
|
+
fail_open_str = os.getenv("RATEFORGE_FAIL_OPEN", "true")
|
|
210
|
+
fail_open = fail_open_str.lower() == "true"
|
|
211
|
+
|
|
212
|
+
details = {
|
|
213
|
+
"fail_open": fail_open,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if not redis_url:
|
|
217
|
+
return HealthCheckResult(
|
|
218
|
+
name="config",
|
|
219
|
+
status="degraded",
|
|
220
|
+
error="RATEFORGE_REDIS_URL not set, using default",
|
|
221
|
+
details=details,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Validate Redis URL format
|
|
225
|
+
if not redis_url.startswith(("redis://", "rediss://")):
|
|
226
|
+
return HealthCheckResult(
|
|
227
|
+
name="config",
|
|
228
|
+
status="unhealthy",
|
|
229
|
+
error="Invalid Redis URL format (must start with redis:// or rediss://)",
|
|
230
|
+
details=details,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
details["redis_url_configured"] = True
|
|
234
|
+
|
|
235
|
+
return HealthCheckResult(
|
|
236
|
+
name="config",
|
|
237
|
+
status="healthy",
|
|
238
|
+
details=details,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
return HealthCheckResult(
|
|
243
|
+
name="config",
|
|
244
|
+
status="unhealthy",
|
|
245
|
+
error=str(exc),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def run_all_checks(self) -> dict[str, HealthCheckResult]:
|
|
249
|
+
"""
|
|
250
|
+
Run all health checks and return results.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Dictionary mapping check names to results
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
>>> checker = HealthChecker()
|
|
257
|
+
>>> results = checker.run_all_checks()
|
|
258
|
+
>>> results["redis"].status
|
|
259
|
+
"healthy"
|
|
260
|
+
"""
|
|
261
|
+
return {
|
|
262
|
+
"redis": self.check_redis(),
|
|
263
|
+
"database": self.check_database(),
|
|
264
|
+
"filesystem": self.check_filesystem(),
|
|
265
|
+
"memory": self.check_memory(),
|
|
266
|
+
"config": self.check_config(),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def get_overall_status(self, results: dict[str, HealthCheckResult]) -> str:
|
|
270
|
+
"""
|
|
271
|
+
Determine overall health status from individual checks.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
results: Dictionary of health check results
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Overall status: "healthy", "degraded", or "unhealthy"
|
|
278
|
+
"""
|
|
279
|
+
statuses = [r.status for r in results.values()]
|
|
280
|
+
|
|
281
|
+
if "unhealthy" in statuses:
|
|
282
|
+
return "unhealthy"
|
|
283
|
+
elif "degraded" in statuses:
|
|
284
|
+
return "degraded"
|
|
285
|
+
else:
|
|
286
|
+
return "healthy"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .django import (
|
|
2
|
+
HealthCheckView as DjangoHealthCheckView,
|
|
3
|
+
)
|
|
4
|
+
from .django import (
|
|
5
|
+
RateLimitMiddleware,
|
|
6
|
+
)
|
|
7
|
+
from .django import (
|
|
8
|
+
rate_limit as django_rate_limit,
|
|
9
|
+
)
|
|
10
|
+
from .django import (
|
|
11
|
+
rate_limit_exception_handler as django_rate_limit_handler,
|
|
12
|
+
)
|
|
13
|
+
from .fastapi import (
|
|
14
|
+
create_health_endpoint,
|
|
15
|
+
rate_limit_dependency,
|
|
16
|
+
setup_rate_limiting,
|
|
17
|
+
)
|
|
18
|
+
from .fastapi import (
|
|
19
|
+
rate_limit as fastapi_rate_limit,
|
|
20
|
+
)
|
|
21
|
+
from .fastapi import (
|
|
22
|
+
rate_limit_exception_handler as fastapi_rate_limit_handler,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"django_rate_limit",
|
|
27
|
+
"RateLimitMiddleware",
|
|
28
|
+
"django_rate_limit_handler",
|
|
29
|
+
"DjangoHealthCheckView",
|
|
30
|
+
"fastapi_rate_limit",
|
|
31
|
+
"rate_limit_dependency",
|
|
32
|
+
"fastapi_rate_limit_handler",
|
|
33
|
+
"setup_rate_limiting",
|
|
34
|
+
"create_health_endpoint",
|
|
35
|
+
]
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django integration for RateForge.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- @rate_limit decorator for Django views
|
|
6
|
+
- RateLimitMiddleware for automatic rate limiting
|
|
7
|
+
- Exception handler for 429 responses
|
|
8
|
+
- Identity extraction from Django requests
|
|
9
|
+
- Health check endpoint
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from functools import wraps
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
from django.conf import settings
|
|
19
|
+
from django.http import HttpRequest, JsonResponse
|
|
20
|
+
from django.views import View
|
|
21
|
+
|
|
22
|
+
from rateforge import (
|
|
23
|
+
RateLimitExceeded,
|
|
24
|
+
RateLimitResponseHandler,
|
|
25
|
+
get_default_limiter,
|
|
26
|
+
)
|
|
27
|
+
from rateforge.health import HealthChecker
|
|
28
|
+
|
|
29
|
+
logger = structlog.get_logger()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rate_limit(
|
|
33
|
+
rate: str,
|
|
34
|
+
*,
|
|
35
|
+
identity: str = "ip",
|
|
36
|
+
bypass_if_authenticated: bool = False,
|
|
37
|
+
) -> Callable:
|
|
38
|
+
"""
|
|
39
|
+
Django rate limit decorator.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
rate: Human-readable rate (e.g., "100/minute", "1000/hour")
|
|
43
|
+
identity: Identity type ("ip", "user", "api_key", "plan")
|
|
44
|
+
bypass_if_authenticated: Skip rate limiting for authenticated users
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Decorated Django view function
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> from rateforge.django import rate_limit
|
|
51
|
+
>>>
|
|
52
|
+
>>> @rate_limit("100/minute")
|
|
53
|
+
... def home(request):
|
|
54
|
+
... return HttpResponse("Hello!")
|
|
55
|
+
>>>
|
|
56
|
+
>>> @rate_limit("10/minute", identity="user")
|
|
57
|
+
... @login_required
|
|
58
|
+
... def create_order(request):
|
|
59
|
+
... ...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def decorator(view_func: Callable) -> Callable:
|
|
63
|
+
@wraps(view_func)
|
|
64
|
+
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
limiter = get_default_limiter()
|
|
66
|
+
|
|
67
|
+
# Check bypass condition
|
|
68
|
+
if bypass_if_authenticated and request.user.is_authenticated:
|
|
69
|
+
return view_func(request, *args, **kwargs)
|
|
70
|
+
|
|
71
|
+
# Extract identity
|
|
72
|
+
try:
|
|
73
|
+
identity_value = _extract_identity(request, identity)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
logger.error(
|
|
76
|
+
"identity_extraction_failed",
|
|
77
|
+
error=str(exc),
|
|
78
|
+
endpoint=request.path,
|
|
79
|
+
)
|
|
80
|
+
if not limiter.fail_open:
|
|
81
|
+
raise
|
|
82
|
+
identity_value = "unknown"
|
|
83
|
+
|
|
84
|
+
# Check rate limit
|
|
85
|
+
try:
|
|
86
|
+
result = limiter.check(
|
|
87
|
+
identity=identity_value,
|
|
88
|
+
endpoint=request.path,
|
|
89
|
+
rate=rate,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Store result in request for middleware
|
|
93
|
+
request._rate_limit_result = result
|
|
94
|
+
|
|
95
|
+
if not result.allowed:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"rate_limit_exceeded",
|
|
98
|
+
identity=identity_value,
|
|
99
|
+
endpoint=request.path,
|
|
100
|
+
limit=result.limit,
|
|
101
|
+
remaining=0,
|
|
102
|
+
retry_after=result.retry_after,
|
|
103
|
+
)
|
|
104
|
+
raise RateLimitExceeded(result)
|
|
105
|
+
|
|
106
|
+
return view_func(request, *args, **kwargs)
|
|
107
|
+
|
|
108
|
+
except RateLimitExceeded:
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.error(
|
|
113
|
+
"rate_limit_error",
|
|
114
|
+
identity=identity_value,
|
|
115
|
+
endpoint=request.path,
|
|
116
|
+
error=str(exc),
|
|
117
|
+
)
|
|
118
|
+
if not limiter.fail_open:
|
|
119
|
+
raise
|
|
120
|
+
return view_func(request, *args, **kwargs)
|
|
121
|
+
|
|
122
|
+
return wrapper
|
|
123
|
+
|
|
124
|
+
return decorator
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_identity(request: HttpRequest, identity_type: str) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Extract identity from Django request.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
request: Django HttpRequest
|
|
133
|
+
identity_type: Type of identity ("ip", "user", "api_key", "plan")
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Identity string (e.g., "ip:192.168.1.1")
|
|
137
|
+
"""
|
|
138
|
+
if identity_type == "ip":
|
|
139
|
+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
140
|
+
if forwarded:
|
|
141
|
+
ip = forwarded.split(",")[0].strip()
|
|
142
|
+
return f"ip:{ip}"
|
|
143
|
+
|
|
144
|
+
ip = request.META.get("REMOTE_ADDR", "unknown")
|
|
145
|
+
return f"ip:{ip}"
|
|
146
|
+
|
|
147
|
+
elif identity_type == "user":
|
|
148
|
+
if hasattr(request, "user") and request.user.is_authenticated:
|
|
149
|
+
user_id = getattr(request.user, "id", None) or getattr(request.user, "pk", None)
|
|
150
|
+
if user_id:
|
|
151
|
+
return f"user:{user_id}"
|
|
152
|
+
raise ValueError("User authenticated but no ID available")
|
|
153
|
+
raise ValueError("User not authenticated")
|
|
154
|
+
|
|
155
|
+
elif identity_type == "api_key":
|
|
156
|
+
api_key = request.headers.get("X-API-Key", "")
|
|
157
|
+
if api_key:
|
|
158
|
+
return f"api_key:{api_key}"
|
|
159
|
+
raise ValueError("API key not provided")
|
|
160
|
+
|
|
161
|
+
elif identity_type == "plan":
|
|
162
|
+
if hasattr(request, "user"):
|
|
163
|
+
plan = getattr(request.user, "plan", None)
|
|
164
|
+
if plan:
|
|
165
|
+
return f"plan:{plan}"
|
|
166
|
+
raise ValueError("User has no plan")
|
|
167
|
+
raise ValueError("Cannot extract plan from request")
|
|
168
|
+
|
|
169
|
+
else:
|
|
170
|
+
raise ValueError(f"Unknown identity type: {identity_type}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def rate_limit_exception_handler(exc: RateLimitExceeded) -> JsonResponse:
|
|
174
|
+
"""
|
|
175
|
+
Build 429 response for RateLimitExceeded exception.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
exc: RateLimitExceeded exception
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
JsonResponse with 429 status and rate limit headers
|
|
182
|
+
"""
|
|
183
|
+
return RateLimitResponseHandler.build_429_response(exc.result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class RateLimitMiddleware:
|
|
187
|
+
"""
|
|
188
|
+
Django middleware for automatic rate limiting.
|
|
189
|
+
|
|
190
|
+
Add to MIDDLEWARE in settings.py:
|
|
191
|
+
MIDDLEWARE = [
|
|
192
|
+
...
|
|
193
|
+
"rateforge.integrations.django.RateLimitMiddleware",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
Configuration in settings.py (optional):
|
|
197
|
+
RATEFORGE_CONFIG = {
|
|
198
|
+
"default_rate": "100/minute",
|
|
199
|
+
"identity": "ip",
|
|
200
|
+
}
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, get_response: Callable):
|
|
204
|
+
self.get_response = get_response
|
|
205
|
+
self.limiter = get_default_limiter()
|
|
206
|
+
|
|
207
|
+
# Optional configuration from settings
|
|
208
|
+
config = getattr(settings, "RATEFORGE_CONFIG", {})
|
|
209
|
+
self.default_rate = config.get("default_rate", "100/minute")
|
|
210
|
+
self.default_identity = config.get("identity", "ip")
|
|
211
|
+
|
|
212
|
+
def __call__(self, request: HttpRequest) -> Any:
|
|
213
|
+
# Apply rate limiting before view
|
|
214
|
+
try:
|
|
215
|
+
identity_value = _extract_identity(request, self.default_identity)
|
|
216
|
+
|
|
217
|
+
result = self.limiter.check(
|
|
218
|
+
identity=identity_value,
|
|
219
|
+
endpoint=request.path,
|
|
220
|
+
rate=self.default_rate,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Store result for later use
|
|
224
|
+
request._rate_limit_result = result
|
|
225
|
+
|
|
226
|
+
if not result.allowed:
|
|
227
|
+
logger.warning(
|
|
228
|
+
"rate_limit_exceeded",
|
|
229
|
+
identity=identity_value,
|
|
230
|
+
endpoint=request.path,
|
|
231
|
+
limit=result.limit,
|
|
232
|
+
remaining=0,
|
|
233
|
+
retry_after=result.retry_after,
|
|
234
|
+
)
|
|
235
|
+
return RateLimitResponseHandler.build_429_response(result)
|
|
236
|
+
|
|
237
|
+
except ValueError as exc:
|
|
238
|
+
logger.error(
|
|
239
|
+
"identity_extraction_failed",
|
|
240
|
+
error=str(exc),
|
|
241
|
+
endpoint=request.path,
|
|
242
|
+
)
|
|
243
|
+
if not self.limiter.fail_open:
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
logger.error(
|
|
248
|
+
"rate_limit_error",
|
|
249
|
+
endpoint=request.path,
|
|
250
|
+
error=str(exc),
|
|
251
|
+
)
|
|
252
|
+
if not self.limiter.fail_open:
|
|
253
|
+
raise
|
|
254
|
+
|
|
255
|
+
# Get response from view
|
|
256
|
+
response = self.get_response(request)
|
|
257
|
+
|
|
258
|
+
# Add rate limit headers to response
|
|
259
|
+
if hasattr(request, "_rate_limit_result"):
|
|
260
|
+
RateLimitResponseHandler.add_rate_limit_headers(
|
|
261
|
+
response,
|
|
262
|
+
request._rate_limit_result,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return response
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def handle_exception(get_response: Callable) -> Callable:
|
|
269
|
+
"""
|
|
270
|
+
Decorator to handle RateLimitExceeded exceptions.
|
|
271
|
+
|
|
272
|
+
Usage:
|
|
273
|
+
@handle_exception
|
|
274
|
+
def my_view(request):
|
|
275
|
+
...
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
@wraps(get_response)
|
|
279
|
+
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
|
|
280
|
+
try:
|
|
281
|
+
return get_response(request, *args, **kwargs)
|
|
282
|
+
except RateLimitExceeded as exc:
|
|
283
|
+
return rate_limit_exception_handler(exc)
|
|
284
|
+
|
|
285
|
+
return wrapper
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class HealthCheckView(View):
|
|
289
|
+
"""
|
|
290
|
+
Django health check endpoint.
|
|
291
|
+
|
|
292
|
+
Returns JSON response with health status and individual check results.
|
|
293
|
+
|
|
294
|
+
Usage:
|
|
295
|
+
# urls.py
|
|
296
|
+
from rateforge.django import HealthCheckView
|
|
297
|
+
|
|
298
|
+
urlpatterns = [
|
|
299
|
+
path('health', HealthCheckView.as_view(), name='health'),
|
|
300
|
+
path('healthz', HealthCheckView.as_view(), name='healthz'),
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
Response format:
|
|
304
|
+
{
|
|
305
|
+
"status": "healthy",
|
|
306
|
+
"timestamp": 1723456789.123,
|
|
307
|
+
"checks": {
|
|
308
|
+
"redis": {"status": "healthy", "latency_ms": 1.4},
|
|
309
|
+
"database": {"status": "healthy", "details": {...}},
|
|
310
|
+
...
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
HTTP Status Codes:
|
|
315
|
+
200: healthy or degraded
|
|
316
|
+
503: unhealthy
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def get(self, request: HttpRequest) -> JsonResponse:
|
|
320
|
+
"""Handle GET request for health check."""
|
|
321
|
+
checker = HealthChecker()
|
|
322
|
+
results = checker.run_all_checks()
|
|
323
|
+
|
|
324
|
+
# Determine overall status
|
|
325
|
+
overall_status = checker.get_overall_status(results)
|
|
326
|
+
|
|
327
|
+
# Set status code based on health
|
|
328
|
+
status_code = 503 if overall_status == "unhealthy" else 200
|
|
329
|
+
|
|
330
|
+
# Format response
|
|
331
|
+
response_data = {
|
|
332
|
+
"status": overall_status,
|
|
333
|
+
"timestamp": time.time(),
|
|
334
|
+
"checks": {name: result.to_dict() for name, result in results.items()},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return JsonResponse(response_data, status=status_code)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|