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 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,5 @@
1
+ """RateForge health module."""
2
+
3
+ from .checks import HealthChecker, HealthCheckResult
4
+
5
+ __all__ = ["HealthChecker", "HealthCheckResult"]
@@ -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
+