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.
- drogue/__init__.py +74 -0
- drogue/adapters/__init__.py +3 -0
- drogue/adapters/django/__init__.py +3 -0
- drogue/adapters/django/limiter.py +266 -0
- drogue/adapters/django/throttle.py +131 -0
- drogue/adapters/fastapi/__init__.py +3 -0
- drogue/adapters/fastapi/limiter.py +541 -0
- drogue/adapters/flask/__init__.py +4 -0
- drogue/adapters/flask/limiter.py +233 -0
- drogue/core/__init__.py +18 -0
- drogue/core/abstracts.py +177 -0
- drogue/core/algorithms/__init__.py +5 -0
- drogue/core/algorithms/fixed_window.py +141 -0
- drogue/core/algorithms/sliding_window.py +171 -0
- drogue/core/algorithms/token_bucket.py +232 -0
- drogue/core/config.py +71 -0
- drogue/core/errors.py +65 -0
- drogue/core/headers.py +56 -0
- drogue/core/identity/__init__.py +17 -0
- drogue/core/identity/key.py +148 -0
- drogue/core/identity/proxy.py +133 -0
- drogue/core/rules/__init__.py +5 -0
- drogue/core/rules/resolver.py +83 -0
- drogue/core/rules/rule.py +117 -0
- drogue/core/storage/__init__.py +3 -0
- drogue/core/storage/memory.py +162 -0
- drogue/core/storage/redis.py +147 -0
- drogue/defense/randomizer.py +318 -0
- drogue/observability/__init__.py +6 -0
- drogue/observability/logging.py +146 -0
- drogue/observability/metrics.py +248 -0
- drogue/observability/opentelemetry.py +204 -0
- drogue/protection/__init__.py +7 -0
- drogue/protection/adaptive.py +180 -0
- drogue/protection/ban.py +175 -0
- drogue/protection/cidr.py +165 -0
- drogue/protection/circuit.py +141 -0
- drogue/protection/ddos.py +312 -0
- drogue/protection/probes.py +289 -0
- drogue/protection/sentinel.py +344 -0
- drogue/protection/trust.py +298 -0
- drogue/storage/probabilistic.py +445 -0
- drogue/utils/__init__.py +3 -0
- drogue/utils/async_utils.py +55 -0
- drogue/utils/time.py +42 -0
- drogue/utils/typing.py +26 -0
- drogue-0.1.0.dist-info/METADATA +278 -0
- drogue-0.1.0.dist-info/RECORD +50 -0
- drogue-0.1.0.dist-info/WHEEL +4 -0
- 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,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)
|