drogue 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.
Files changed (50) hide show
  1. drogue/__init__.py +74 -0
  2. drogue/adapters/__init__.py +3 -0
  3. drogue/adapters/django/__init__.py +3 -0
  4. drogue/adapters/django/limiter.py +266 -0
  5. drogue/adapters/django/throttle.py +131 -0
  6. drogue/adapters/fastapi/__init__.py +3 -0
  7. drogue/adapters/fastapi/limiter.py +541 -0
  8. drogue/adapters/flask/__init__.py +4 -0
  9. drogue/adapters/flask/limiter.py +233 -0
  10. drogue/core/__init__.py +18 -0
  11. drogue/core/abstracts.py +177 -0
  12. drogue/core/algorithms/__init__.py +5 -0
  13. drogue/core/algorithms/fixed_window.py +141 -0
  14. drogue/core/algorithms/sliding_window.py +171 -0
  15. drogue/core/algorithms/token_bucket.py +232 -0
  16. drogue/core/config.py +71 -0
  17. drogue/core/errors.py +65 -0
  18. drogue/core/headers.py +56 -0
  19. drogue/core/identity/__init__.py +17 -0
  20. drogue/core/identity/key.py +148 -0
  21. drogue/core/identity/proxy.py +133 -0
  22. drogue/core/rules/__init__.py +5 -0
  23. drogue/core/rules/resolver.py +83 -0
  24. drogue/core/rules/rule.py +117 -0
  25. drogue/core/storage/__init__.py +3 -0
  26. drogue/core/storage/memory.py +162 -0
  27. drogue/core/storage/redis.py +147 -0
  28. drogue/defense/randomizer.py +318 -0
  29. drogue/observability/__init__.py +6 -0
  30. drogue/observability/logging.py +146 -0
  31. drogue/observability/metrics.py +248 -0
  32. drogue/observability/opentelemetry.py +204 -0
  33. drogue/protection/__init__.py +7 -0
  34. drogue/protection/adaptive.py +180 -0
  35. drogue/protection/ban.py +175 -0
  36. drogue/protection/cidr.py +165 -0
  37. drogue/protection/circuit.py +141 -0
  38. drogue/protection/ddos.py +312 -0
  39. drogue/protection/probes.py +289 -0
  40. drogue/protection/sentinel.py +344 -0
  41. drogue/protection/trust.py +298 -0
  42. drogue/storage/probabilistic.py +445 -0
  43. drogue/utils/__init__.py +3 -0
  44. drogue/utils/async_utils.py +55 -0
  45. drogue/utils/time.py +42 -0
  46. drogue/utils/typing.py +26 -0
  47. drogue-0.1.0.dist-info/METADATA +278 -0
  48. drogue-0.1.0.dist-info/RECORD +50 -0
  49. drogue-0.1.0.dist-info/WHEEL +4 -0
  50. drogue-0.1.0.dist-info/licenses/LICENSE +21 -0
drogue/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Drogue - Production-ready rate limiting and DDoS protection.
2
+
3
+ Usage:
4
+ from drogue import DrogueLimiter, RateLimitRule
5
+
6
+ # FastAPI
7
+ app = FastAPI()
8
+ limiter = DrogueLimiter(app)
9
+
10
+ @app.get("/api/data")
11
+ @limiter.limit("100/minute")
12
+ async def get_data():
13
+ return {"data": "value"}
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from drogue.core.abstracts import AcquireResult, Algorithm, IdentityExtractor, Storage
19
+ from drogue.core.algorithms import (
20
+ FixedWindowAlgorithm,
21
+ SlidingWindowAlgorithm,
22
+ TokenBucketAlgorithm,
23
+ )
24
+ from drogue.core.config import DrogueConfig
25
+ from drogue.core.errors import (
26
+ BackendFailure,
27
+ BanError,
28
+ ConfigurationError,
29
+ RateLimitExceeded,
30
+ StorageError,
31
+ )
32
+ from drogue.core.identity import (
33
+ CompositeExtractor,
34
+ HeaderExtractor,
35
+ RemoteAddressExtractor,
36
+ UserExtractor,
37
+ )
38
+ from drogue.core.rules.rule import AlgorithmType, RateLimitRule, parse_rule_string
39
+ from drogue.core.storage.memory import MemoryStorage
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ # Core
45
+ "Algorithm",
46
+ "Storage",
47
+ "IdentityExtractor",
48
+ "AcquireResult",
49
+ # Config
50
+ "DrogueConfig",
51
+ # Errors
52
+ "RateLimitExceeded",
53
+ "BackendFailure",
54
+ "BanError",
55
+ "ConfigurationError",
56
+ "StorageError",
57
+ # Rules
58
+ "RateLimitRule",
59
+ "AlgorithmType",
60
+ "parse_rule_string",
61
+ # Storage
62
+ "MemoryStorage",
63
+ # Algorithms
64
+ "TokenBucketAlgorithm",
65
+ "SlidingWindowAlgorithm",
66
+ "FixedWindowAlgorithm",
67
+ # Identity
68
+ "RemoteAddressExtractor",
69
+ "UserExtractor",
70
+ "HeaderExtractor",
71
+ "CompositeExtractor",
72
+ # Version
73
+ "__version__",
74
+ ]
@@ -0,0 +1,3 @@
1
+ from drogue.adapters.fastapi.limiter import DrogueLimiter
2
+
3
+ __all__ = ["DrogueLimiter"]
@@ -0,0 +1,3 @@
1
+ from drogue.adapters.django.limiter import DrogueMiddleware, DrogueRateLimiter
2
+
3
+ __all__ = ["DrogueRateLimiter", "DrogueMiddleware"]
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from django.conf import settings
8
+ from django.http import HttpRequest, JsonResponse
9
+ from django.utils.deprecation import MiddlewareMixin
10
+
11
+ from drogue.core.algorithms import (
12
+ FixedWindowAlgorithm,
13
+ SlidingWindowAlgorithm,
14
+ TokenBucketAlgorithm,
15
+ )
16
+ from drogue.core.config import DrogueConfig
17
+ from drogue.core.headers import build_429_response
18
+ from drogue.core.identity import RemoteAddressExtractor
19
+ from drogue.core.rules.resolver import CostResolver
20
+ from drogue.core.rules.rule import AlgorithmType, RateLimitRule, parse_rule_string
21
+ from drogue.core.storage.memory import MemoryStorage
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable
25
+
26
+ from drogue.core.abstracts import AcquireResult, Algorithm, IdentityExtractor, Storage
27
+
28
+ logger = logging.getLogger("drogue.django")
29
+
30
+ _ALGORITHM_MAP: dict[AlgorithmType, type[Algorithm]] = {
31
+ AlgorithmType.TOKEN_BUCKET: TokenBucketAlgorithm,
32
+ AlgorithmType.SLIDING_WINDOW: SlidingWindowAlgorithm,
33
+ AlgorithmType.FIXED_WINDOW: FixedWindowAlgorithm,
34
+ }
35
+
36
+ # Thread-local storage for limiter instance
37
+ _local: Any = None
38
+ try:
39
+ import threading
40
+ _local = threading.local()
41
+ except ImportError:
42
+ pass
43
+
44
+
45
+ def _get_limiter() -> DrogueRateLimiter:
46
+ """Get the current limiter instance from thread-local or settings."""
47
+ if _local is not None and hasattr(_local, "limiter"):
48
+ return _local.limiter
49
+ return getattr(settings, "DROGUE_LIMITER", None) # type: ignore[no-any-return]
50
+
51
+
52
+ class DrogueRateLimiter:
53
+ """Main entry point for Django rate limiting.
54
+
55
+ Usage (in settings.py):
56
+ DROGUE_LIMITER = DrogueRateLimiter(default_limits=["100/minute"])
57
+
58
+ Usage (in views.py):
59
+ from drogue.django import ratelimit
60
+
61
+ @ratelimit("100/minute")
62
+ def my_view(request):
63
+ return JsonResponse({"data": "value"})
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ rules: list[RateLimitRule] | None = None,
70
+ storage: Storage | None = None,
71
+ key_func: IdentityExtractor | None = None,
72
+ config: DrogueConfig | None = None,
73
+ default_limits: list[str] | None = None,
74
+ ) -> None:
75
+ self.config = config or DrogueConfig()
76
+ self.storage = storage if storage is not None else MemoryStorage()
77
+ self.key_func = key_func or RemoteAddressExtractor(
78
+ trusted_proxies=self.config.trusted_proxies,
79
+ trust_x_real_ip=self.config.trust_x_real_ip,
80
+ )
81
+ self._algorithms: dict[str, Algorithm] = {}
82
+ self._route_rules: dict[str, list[RateLimitRule]] = {}
83
+ self._initialized = False
84
+
85
+ # Global rules
86
+ self._global_rules: list[RateLimitRule] = list(rules or [])
87
+ for limit_str in (default_limits or []):
88
+ rule = parse_rule_string(limit_str)
89
+ object.__setattr__(rule, "scope", "global")
90
+ self._global_rules.append(rule)
91
+
92
+ def initialize(self) -> None:
93
+ """Initialize storage backend. Call once at startup."""
94
+ _run_async(self.storage.initialize())
95
+ self._initialized = True
96
+
97
+ def _get_algorithm(self, rule: RateLimitRule, route_key: str = "") -> Algorithm:
98
+ """Get or create algorithm instance for a rule."""
99
+ algo_class = _ALGORITHM_MAP[rule.algorithm]
100
+ cache_key = f"{route_key}:{rule.algorithm.value}:{rule.limit}:{rule.window}"
101
+ if cache_key not in self._algorithms:
102
+ self._algorithms[cache_key] = algo_class(
103
+ storage=self.storage,
104
+ limit=rule.limit,
105
+ window=rule.window,
106
+ )
107
+ return self._algorithms[cache_key]
108
+
109
+ async def _check(
110
+ self,
111
+ key: str,
112
+ rule: RateLimitRule,
113
+ context: dict[str, Any] | None = None,
114
+ route_key: str = "",
115
+ ) -> AcquireResult:
116
+ """Check a single rate limit rule."""
117
+ algo = self._get_algorithm(rule, route_key)
118
+ cost = await CostResolver.resolve_cost(rule, context)
119
+ storage_key = f"{route_key}:{key}" if route_key else key
120
+ return await algo.acquire(storage_key, cost=cost, block=rule.block, timeout=rule.timeout)
121
+
122
+ def _check_sync(
123
+ self,
124
+ key: str,
125
+ rule: RateLimitRule,
126
+ context: dict[str, Any] | None = None,
127
+ route_key: str = "",
128
+ ) -> AcquireResult:
129
+ """Synchronous check (for Django views)."""
130
+ import asyncio
131
+ try:
132
+ loop = asyncio.get_running_loop()
133
+ except RuntimeError:
134
+ loop = None
135
+
136
+ if loop and loop.is_running():
137
+ # We're in an async context, create a new event loop
138
+ import concurrent.futures
139
+ with concurrent.futures.ThreadPoolExecutor() as pool:
140
+ future = pool.submit(
141
+ asyncio.run,
142
+ self._check(key, rule, context, route_key),
143
+ )
144
+ return future.result()
145
+ else:
146
+ return asyncio.run(self._check(key, rule, context, route_key))
147
+
148
+ def limit(
149
+ self,
150
+ rule_str: str,
151
+ *,
152
+ algorithm: AlgorithmType = AlgorithmType.TOKEN_BUCKET,
153
+ block: bool = False,
154
+ key_func: IdentityExtractor | None = None,
155
+ ) -> Callable:
156
+ """Decorator for view-level rate limiting.
157
+
158
+ Usage:
159
+ @ratelimit("100/minute")
160
+ def my_view(request):
161
+ return JsonResponse({"data": "value"})
162
+ """
163
+ rule = parse_rule_string(rule_str, algorithm=algorithm, block=block)
164
+
165
+ def decorator(view_func: Callable) -> Callable:
166
+ func_name = f"{view_func.__module__}.{view_func.__qualname__}"
167
+ self._route_rules.setdefault(func_name, []).append(rule)
168
+
169
+ @functools.wraps(view_func)
170
+ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any:
171
+ context = _request_to_context(request)
172
+ extractor = key_func or self.key_func
173
+ key = _run_async(extractor.extract(context))
174
+
175
+ for r in self._route_rules.get(func_name, [rule]):
176
+ result = self._check_sync(key, r, context, route_key=func_name)
177
+ if not result.allowed:
178
+ return JsonResponse(
179
+ build_429_response(result),
180
+ status=429,
181
+ headers=result.headers,
182
+ )
183
+
184
+ response = view_func(request, *args, **kwargs)
185
+
186
+ # Inject headers from the first check result
187
+ if hasattr(response, "items") and rule.headers:
188
+ for header, value in result.headers.items():
189
+ response[header] = value
190
+
191
+ return response
192
+
193
+ return wrapper
194
+
195
+ return decorator
196
+
197
+
198
+ class DrogueMiddleware(MiddlewareMixin):
199
+ """Django middleware for global rate limiting.
200
+
201
+ Add to MIDDLEWARE in settings.py:
202
+ MIDDLEWARE = [
203
+ ...
204
+ 'drogue.django.middleware.DrogueMiddleware',
205
+ ]
206
+ """
207
+
208
+ def process_request(self, request: HttpRequest) -> JsonResponse | None:
209
+ from drogue.core.errors import BackendFailure
210
+
211
+ limiter = _get_limiter()
212
+ if limiter is None:
213
+ return None
214
+
215
+ try:
216
+ context = _request_to_context(request)
217
+ key = _run_async(limiter.key_func.extract(context))
218
+
219
+ for rule in limiter._global_rules:
220
+ result = limiter._check_sync(key, rule, context, route_key="__global__")
221
+ if not result.allowed:
222
+ return JsonResponse(
223
+ build_429_response(result),
224
+ status=429,
225
+ headers=result.headers,
226
+ )
227
+
228
+ return None
229
+
230
+ except BackendFailure:
231
+ # Fail-closed: deny request when backend is unavailable
232
+ return JsonResponse(
233
+ {"error": "Rate limit service unavailable", "retry_after": 1},
234
+ status=429,
235
+ headers={"Retry-After": "1"},
236
+ )
237
+
238
+
239
+ def _request_to_context(request: HttpRequest) -> dict[str, Any]:
240
+ """Convert a Django HttpRequest to a context dict."""
241
+ # Get real IP from proxy headers
242
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
243
+ x_real_ip = request.META.get("HTTP_X_REAL_IP")
244
+
245
+ if x_real_ip:
246
+ client_host = x_real_ip.strip()
247
+ elif x_forwarded_for:
248
+ client_host = x_forwarded_for.split(",")[0].strip()
249
+ else:
250
+ client_host = request.META.get("REMOTE_ADDR", "127.0.0.1")
251
+
252
+ return {
253
+ "client": {"host": client_host},
254
+ "headers": {k.lower().replace("http_", ""): v for k, v in request.META.items() if k.startswith("HTTP_")},
255
+ "path": request.path,
256
+ "method": request.method,
257
+ "query_params": dict(request.GET),
258
+ "state": getattr(request, "_drogue_state", {}),
259
+ "request": request,
260
+ }
261
+
262
+
263
+ def _run_async(coro: Any) -> Any:
264
+ """Run an async coroutine from sync code."""
265
+ from drogue.utils.async_utils import run_async
266
+ return run_async(coro)
@@ -0,0 +1,131 @@
1
+ """Django REST Framework throttle adapter using drogue.
2
+
3
+ Provides DrogueThrottle that plugs into DRF's throttling system,
4
+ using drogue's algorithms instead of DRF's built-in throttling.
5
+
6
+ Usage in settings.py:
7
+ REST_FRAMEWORK = {
8
+ 'DEFAULT_THROTTLE_CLASSES': ['drogue.adapters.django.throttle.DrogueThrottle'],
9
+ 'DEFAULT_THROTTLE_RATES': {
10
+ 'user': '100/hour',
11
+ 'anon': '20/minute',
12
+ }
13
+ }
14
+
15
+ Usage per-view:
16
+ from drogue.adapters.django.throttle import DrogueThrottle
17
+
18
+ class MyView(APIView):
19
+ throttle_classes = [DrogueThrottle]
20
+ throttle_scope = 'user'
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from django.conf import settings
27
+ from rest_framework.throttling import BaseThrottle
28
+
29
+ from drogue.core.algorithms import (
30
+ FixedWindowAlgorithm,
31
+ SlidingWindowAlgorithm,
32
+ TokenBucketAlgorithm,
33
+ )
34
+ from drogue.core.rules.rule import AlgorithmType, parse_rule_string
35
+ from drogue.core.storage.memory import MemoryStorage
36
+
37
+ if TYPE_CHECKING:
38
+ from drogue.core.abstracts import Algorithm
39
+
40
+ _ALGORITHM_MAP: dict[AlgorithmType, type[Algorithm]] = {
41
+ AlgorithmType.TOKEN_BUCKET: TokenBucketAlgorithm,
42
+ AlgorithmType.SLIDING_WINDOW: SlidingWindowAlgorithm,
43
+ AlgorithmType.FIXED_WINDOW: FixedWindowAlgorithm,
44
+ }
45
+
46
+ # Shared storage and algorithms across throttle instances
47
+ _storage: MemoryStorage | None = None
48
+ _algorithms: dict[str, Algorithm] = {}
49
+ _MAX_ALGORITHMS = 256
50
+
51
+
52
+ def _get_storage() -> MemoryStorage:
53
+ global _storage
54
+ if _storage is None:
55
+ _storage = MemoryStorage()
56
+ return _storage
57
+
58
+
59
+ def _get_algorithm(rule_str: str, route_key: str = "") -> Algorithm:
60
+ cache_key = f"{route_key}:{rule_str}"
61
+ if cache_key not in _algorithms:
62
+ if len(_algorithms) >= _MAX_ALGORITHMS:
63
+ _algorithms.clear()
64
+ rule = parse_rule_string(rule_str)
65
+ algo_class = _ALGORITHM_MAP[rule.algorithm]
66
+ _algorithms[cache_key] = algo_class(
67
+ storage=_get_storage(),
68
+ limit=rule.limit,
69
+ window=rule.window,
70
+ )
71
+ return _algorithms[cache_key]
72
+
73
+
74
+ class DrogueThrottle(BaseThrottle):
75
+ """DRF throttle using drogue's rate limiting algorithms.
76
+
77
+ Configure rate via DRF's DEFAULT_THROTTLE_RATES or per-view:
78
+ throttle_scope = 'user'
79
+ throttle_rates = {'user': '100/hour'}
80
+
81
+ Supports:
82
+ - Per-user rate limiting (authenticated users)
83
+ - Per-IP rate limiting (anonymous users)
84
+ - All drogue algorithms (token_bucket, sliding_window, fixed_window)
85
+ """
86
+
87
+ def allow_request(self, request: Any, view: Any) -> bool:
88
+ """Check if the request should be allowed."""
89
+ self.key = self.get_cache_key(request, view)
90
+ if self.key is None:
91
+ return True
92
+
93
+ # Get rate from view or global settings
94
+ rate = self.get_rate(view)
95
+ if rate is None:
96
+ return True
97
+
98
+ algo = _get_algorithm(rate, route_key=f"drf:{getattr(view, 'throttle_scope', 'default')}")
99
+
100
+ # Run async acquire synchronously
101
+ from drogue.utils.async_utils import run_async
102
+ result = run_async(algo.acquire(self.key))
103
+
104
+ self._wait_after = result.retry_after
105
+ return result.allowed
106
+
107
+ def get_rate(self, view: Any) -> str | None:
108
+ """Get the throttle rate for the current view."""
109
+ # Check view-level throttle_rates first
110
+ throttle_rates = getattr(view, "throttle_rates", None)
111
+ scope = getattr(view, "throttle_scope", None)
112
+
113
+ if throttle_rates and scope and scope in throttle_rates:
114
+ return throttle_rates[scope]
115
+
116
+ # Fall back to DRF global settings
117
+ rates = getattr(settings, "REST_FRAMEWORK", {}).get("DEFAULT_THROTTLE_RATES", {})
118
+ if scope and scope in rates:
119
+ return rates[scope]
120
+
121
+ return None
122
+
123
+ def get_cache_key(self, request: Any, view: Any) -> str | None:
124
+ """Generate cache key for the request."""
125
+ if request.user and request.user.is_authenticated:
126
+ return f"throttle:user:{request.user.pk}"
127
+ return f"throttle:ip:{self.get_ident(request)}"
128
+
129
+ def wait(self) -> float | None: # type: ignore[override]
130
+ """Seconds to wait before next request, or None if allowed."""
131
+ return getattr(self, "_wait_after", None)
@@ -0,0 +1,3 @@
1
+ from drogue.adapters.fastapi.limiter import DrogueLimiter
2
+
3
+ __all__ = ["DrogueLimiter"]