Skip to content

Commit d770ecf

Browse files
committed
Add Django rate-limiting integration
Introduce a Django integration module providing: DjangoRateLimitMiddleware (reads RATE_LIMITING settings, supports default limit/window, custom key function, skip paths, and limiter import), a django_rate_limit view decorator, rate_limit_exempt marker, RateLimitMixin for class-based views, and get_rate_limiter_from_settings helper. Responses include 429 JSON payloads and standard Retry-After / X-RateLimit headers; by_ip is used as the default key generator and the limiter falls back to myapp.rate_limiter if configured.
1 parent 67a12a0 commit d770ecf

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

ratelink/integrations/django.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import functools
2+
from typing import Any, Callable, Optional
3+
from django.conf import settings
4+
from django.http import HttpResponse, JsonResponse
5+
from django.utils.decorators import method_decorator
6+
from django.utils.module_loading import import_strin
7+
from ratelink.utils.key_generators import KeyGeneratorFunc, by_ip
8+
9+
class DjangoRateLimitMiddleware:
10+
def __init__(self, get_response):
11+
self.get_response = get_response
12+
13+
config = getattr(settings, 'RATE_LIMITING', {})
14+
15+
default = config.get('DEFAULT', {})
16+
self.default_limit = default.get('limit')
17+
self.default_window = default.get('window')
18+
19+
key_func_path = config.get('KEY_FUNC')
20+
if key_func_path:
21+
self.key_func = import_string(key_func_path)()
22+
else:
23+
self.key_func = by_ip()
24+
25+
self.skip_paths = set(config.get('SKIP_PATHS', []))
26+
27+
limiter_path = config.get('LIMITER')
28+
if limiter_path:
29+
self.limiter = import_string(limiter_path)
30+
else:
31+
try:
32+
from myapp.rate_limiter import limiter
33+
self.limiter = limiter
34+
except ImportError:
35+
self.limiter = None
36+
37+
def __call__(self, request):
38+
if self.limiter is None:
39+
return self.get_response(request)
40+
41+
if request.path in self.skip_paths:
42+
return self.get_response(request)
43+
44+
if hasattr(request, 'resolver_match') and request.resolver_match:
45+
func = request.resolver_match.func
46+
if hasattr(func, '_rate_limit_exempt'):
47+
return self.get_response(request)
48+
49+
key = self.key_func(request)
50+
51+
allowed, state = self.limiter.check(key)
52+
53+
if not allowed:
54+
return self._make_error_response(state)
55+
56+
response = self.get_response(request)
57+
response['X-RateLimit-Limit'] = str(state.get('limit', 0))
58+
response['X-RateLimit-Remaining'] = str(state.get('remaining', 0))
59+
60+
return response
61+
62+
def _make_error_response(self, state: dict) -> JsonResponse:
63+
retry_after = state.get('retry_after', 0)
64+
65+
response = JsonResponse({
66+
"error": "Rate limit exceeded",
67+
"limit": state.get('limit', 0),
68+
"remaining": 0,
69+
"retry_after": retry_after
70+
}, status=429)
71+
72+
response['Retry-After'] = str(int(retry_after))
73+
response['X-RateLimit-Limit'] = str(state.get('limit', 0))
74+
response['X-RateLimit-Remaining'] = '0'
75+
76+
return response
77+
78+
79+
def django_rate_limit(
80+
limiter: Any = None,
81+
limit: Optional[int] = None,
82+
window: Optional[int] = None,
83+
key_func: Optional[KeyGeneratorFunc] = None
84+
) -> Callable:
85+
if key_func is None:
86+
key_func = by_ip()
87+
88+
def decorator(func: Callable) -> Callable:
89+
@functools.wraps(func)
90+
def wrapper(request, *args, **kwargs):
91+
actual_limiter = limiter
92+
if actual_limiter is None:
93+
try:
94+
from myapp.rate_limiter import limiter as global_limiter
95+
actual_limiter = global_limiter
96+
except ImportError:
97+
# No limiter available, skip
98+
return func(request, *args, **kwargs)
99+
100+
key = key_func(request)
101+
102+
allowed, state = actual_limiter.check(key)
103+
104+
if not allowed:
105+
retry_after = state.get('retry_after', 0)
106+
response = JsonResponse({
107+
"error": "Rate limit exceeded",
108+
"limit": state.get('limit', 0),
109+
"remaining": 0,
110+
"retry_after": retry_after
111+
}, status=429)
112+
113+
response['Retry-After'] = str(int(retry_after))
114+
response['X-RateLimit-Limit'] = str(state.get('limit', 0))
115+
response['X-RateLimit-Remaining'] = '0'
116+
117+
return response
118+
119+
return func(request, *args, **kwargs)
120+
121+
return wrapper
122+
123+
return decorator
124+
125+
126+
def rate_limit_exempt(func: Callable) -> Callable:
127+
func._rate_limit_exempt = True
128+
return func
129+
130+
131+
class RateLimitMixin:
132+
rate_limit_limiter = None
133+
rate_limit_key_func = None
134+
rate_limit = None
135+
rate_window = None
136+
137+
@method_decorator(django_rate_limit)
138+
def dispatch(self, request, *args, **kwargs):
139+
if self.rate_limit_limiter:
140+
key_func = self.rate_limit_key_func or by_ip()
141+
key = key_func(request)
142+
143+
allowed, state = self.rate_limit_limiter.check(key)
144+
145+
if not allowed:
146+
retry_after = state.get('retry_after', 0)
147+
response = JsonResponse({
148+
"error": "Rate limit exceeded",
149+
"retry_after": retry_after
150+
}, status=429)
151+
response['Retry-After'] = str(int(retry_after))
152+
return response
153+
154+
return super().dispatch(request, *args, **kwargs)
155+
156+
157+
def get_rate_limiter_from_settings():
158+
config = getattr(settings, 'RATE_LIMITING', {})
159+
limiter_path = config.get('LIMITER')
160+
161+
if limiter_path:
162+
return import_string(limiter_path)
163+
164+
return None

0 commit comments

Comments
 (0)