pycurb 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.
- pycurb/__init__.py +0 -0
- pycurb/adapters/django/__init__.py +11 -0
- pycurb/adapters/django/decorators.py +77 -0
- pycurb/adapters/django/extractors.py +25 -0
- pycurb/adapters/django/middleware.py +46 -0
- pycurb/adapters/fastapi/__init__.py +17 -0
- pycurb/adapters/fastapi/dependencies.py +30 -0
- pycurb/adapters/fastapi/extractors.py +24 -0
- pycurb/adapters/fastapi/middleware.py +53 -0
- pycurb/adapters/flask/__init__.py +17 -0
- pycurb/adapters/flask/decorators.py +44 -0
- pycurb/adapters/flask/extractors.py +35 -0
- pycurb/adapters/flask/middleware.py +54 -0
- pycurb/core/__init__.py +24 -0
- pycurb/core/algorithms/__init__.py +12 -0
- pycurb/core/algorithms/base.py +21 -0
- pycurb/core/algorithms/base_async.py +23 -0
- pycurb/core/algorithms/fixed_window.py +26 -0
- pycurb/core/algorithms/fixed_window_async.py +28 -0
- pycurb/core/algorithms/gcra.py +38 -0
- pycurb/core/algorithms/gcra_async.py +40 -0
- pycurb/core/algorithms/leaky_bucket.py +40 -0
- pycurb/core/algorithms/leaky_bucket_async.py +42 -0
- pycurb/core/algorithms/sliding_window.py +25 -0
- pycurb/core/algorithms/sliding_window_async.py +28 -0
- pycurb/core/algorithms/token_bucket.py +39 -0
- pycurb/core/algorithms/token_bucket_async.py +41 -0
- pycurb/core/decorators.py +155 -0
- pycurb/core/limiter.py +114 -0
- pycurb/core/limiter_async.py +123 -0
- pycurb/core/models.py +271 -0
- pycurb/core/resolver.py +97 -0
- pycurb/core/storage/__init__.py +8 -0
- pycurb/core/storage/base.py +123 -0
- pycurb/core/storage/base_async.py +123 -0
- pycurb/core/storage/memory.py +156 -0
- pycurb/core/storage/memory_async.py +156 -0
- pycurb/core/storage/redis.py +360 -0
- pycurb/core/storage/redis_async.py +371 -0
- pycurb/utils.py +111 -0
- pycurb-0.1.0.dist-info/METADATA +256 -0
- pycurb-0.1.0.dist-info/RECORD +45 -0
- pycurb-0.1.0.dist-info/WHEEL +5 -0
- pycurb-0.1.0.dist-info/licenses/LICENSE +201 -0
- pycurb-0.1.0.dist-info/top_level.txt +1 -0
pycurb/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .extractors import ip_extractor, user_id_extractor, api_key_extractor
|
|
2
|
+
from .decorators import rate_limit
|
|
3
|
+
from .middleware import create_rate_limit_middleware
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ip_extractor",
|
|
7
|
+
"user_id_extractor",
|
|
8
|
+
"api_key_extractor",
|
|
9
|
+
"rate_limit",
|
|
10
|
+
"create_rate_limit_middleware",
|
|
11
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Union, List, Callable, Optional, cast
|
|
4
|
+
from django.http import JsonResponse, HttpRequest
|
|
5
|
+
from pycurb.core import RateLimiter, AsyncRateLimiter
|
|
6
|
+
from pycurb.core.models import RateLimitHeaders
|
|
7
|
+
from .extractors import ip_extractor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rate_limit(
|
|
11
|
+
limiter: Union[RateLimiter, AsyncRateLimiter],
|
|
12
|
+
rule_name: Union[str, List[str]],
|
|
13
|
+
key_extractor: Callable[[HttpRequest], str] = ip_extractor,
|
|
14
|
+
on_limit: Optional[Callable] = None,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Django view decorator for rate limiting.
|
|
18
|
+
Works for sync and async views.
|
|
19
|
+
Returns 429 JSON response when limit exceeded.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def decorator(view_func):
|
|
23
|
+
is_async = inspect.iscoroutinefunction(view_func)
|
|
24
|
+
limiter_is_async = isinstance(limiter, AsyncRateLimiter) # RateLimiter is async
|
|
25
|
+
|
|
26
|
+
if is_async and not limiter_is_async:
|
|
27
|
+
raise TypeError(
|
|
28
|
+
"Async view requires an async RateLimiter (use AsyncRateLimiter, not RateLimiter)"
|
|
29
|
+
)
|
|
30
|
+
if not is_async and limiter_is_async:
|
|
31
|
+
raise TypeError(
|
|
32
|
+
"Sync view requires a sync RateLimiter (use RateLimiter, not AsyncRateLimiter)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@functools.wraps(view_func)
|
|
36
|
+
def sync_wrapper(request, *args, **kwargs):
|
|
37
|
+
key = key_extractor(request)
|
|
38
|
+
limiter_sync = cast(RateLimiter, limiter)
|
|
39
|
+
result = limiter_sync.check(key, rule_name)
|
|
40
|
+
if not result.allowed:
|
|
41
|
+
if on_limit:
|
|
42
|
+
return on_limit(request, result)
|
|
43
|
+
headers = RateLimitHeaders.from_result(result)
|
|
44
|
+
response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
|
|
45
|
+
for name, value in headers.to_dict().items():
|
|
46
|
+
response[name] = value
|
|
47
|
+
return response
|
|
48
|
+
|
|
49
|
+
response = view_func(request, *args, **kwargs)
|
|
50
|
+
headers = RateLimitHeaders.from_result(result)
|
|
51
|
+
for name, value in headers.to_dict().items():
|
|
52
|
+
response[name] = value
|
|
53
|
+
return response
|
|
54
|
+
|
|
55
|
+
@functools.wraps(view_func)
|
|
56
|
+
async def async_wrapper(request, *args, **kwargs):
|
|
57
|
+
key = key_extractor(request)
|
|
58
|
+
limiter_async = cast(AsyncRateLimiter, limiter)
|
|
59
|
+
result = await limiter_async.check(key, rule_name)
|
|
60
|
+
if not result.allowed:
|
|
61
|
+
if on_limit:
|
|
62
|
+
return on_limit(request, result)
|
|
63
|
+
headers = RateLimitHeaders.from_result(result)
|
|
64
|
+
response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
|
|
65
|
+
for name, value in headers.to_dict().items():
|
|
66
|
+
response[name] = value
|
|
67
|
+
return response
|
|
68
|
+
|
|
69
|
+
response = await view_func(request, *args, **kwargs)
|
|
70
|
+
headers = RateLimitHeaders.from_result(result)
|
|
71
|
+
for name, value in headers.to_dict().items():
|
|
72
|
+
response[name] = value
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
return async_wrapper if is_async else sync_wrapper
|
|
76
|
+
|
|
77
|
+
return decorator
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.http import HttpRequest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def ip_extractor(request: HttpRequest) -> str:
|
|
5
|
+
"""Extract client IP from request, respecting X-Forwarded-For."""
|
|
6
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
7
|
+
if forwarded:
|
|
8
|
+
return forwarded.split(",")[0].strip()
|
|
9
|
+
return request.META.get("REMOTE_ADDR", "unknown")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def user_id_extractor(request: HttpRequest) -> str:
|
|
13
|
+
"""Extract authenticated user ID (or 'anon')."""
|
|
14
|
+
if hasattr(request, "user") and request.user.is_authenticated:
|
|
15
|
+
return str(request.user.pk)
|
|
16
|
+
return "anon"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def api_key_extractor(request: HttpRequest, header: str = "X-API-Key") -> str:
|
|
20
|
+
"""Extract API key from a custom header."""
|
|
21
|
+
return request.headers.get(header, "")
|
|
22
|
+
|
|
23
|
+
def custom_extractor(extractor_func):
|
|
24
|
+
"""Decorator to register custom extractors (optional)."""
|
|
25
|
+
return extractor_func
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from django.http import JsonResponse
|
|
2
|
+
from typing import Callable, Union
|
|
3
|
+
from pycurb.core import RateLimiter, AsyncRateLimiter
|
|
4
|
+
from pycurb.core.models import RateLimitHeaders
|
|
5
|
+
from .extractors import ip_extractor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_rate_limit_middleware(
|
|
9
|
+
limiter: Union[RateLimiter, AsyncRateLimiter],
|
|
10
|
+
rule_name: str,
|
|
11
|
+
key_extractor: Callable = ip_extractor,
|
|
12
|
+
):
|
|
13
|
+
"""
|
|
14
|
+
Factory that returns a Django middleware class for global rate limiting.
|
|
15
|
+
Works with sync limiter for now.
|
|
16
|
+
If you use async limiter, you must ensure it is compatible with sync calls
|
|
17
|
+
(e.g., by using asyncio.run). For simplicity, use RateLimiter.
|
|
18
|
+
"""
|
|
19
|
+
if isinstance(limiter, AsyncRateLimiter):
|
|
20
|
+
raise TypeError(
|
|
21
|
+
"Out-of-box Middleware only supports a sync limiter for now. Use RateLimiter."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class RateLimitMiddleware:
|
|
25
|
+
def __init__(self, get_response):
|
|
26
|
+
self.get_response = get_response
|
|
27
|
+
self.limiter = limiter
|
|
28
|
+
self.rule_name = rule_name
|
|
29
|
+
self.key_extractor = key_extractor
|
|
30
|
+
|
|
31
|
+
def __call__(self, request):
|
|
32
|
+
key = self.key_extractor(request)
|
|
33
|
+
result = self.limiter.check(key, self.rule_name)
|
|
34
|
+
if not result.allowed:
|
|
35
|
+
headers = RateLimitHeaders.from_result(result)
|
|
36
|
+
response = JsonResponse({"detail": "Rate limit exceeded"}, status=429)
|
|
37
|
+
for name, value in headers.to_dict().items():
|
|
38
|
+
response[name] = value
|
|
39
|
+
return response
|
|
40
|
+
response = self.get_response(request)
|
|
41
|
+
headers = RateLimitHeaders.from_result(result)
|
|
42
|
+
for name, value in headers.to_dict().items():
|
|
43
|
+
response[name] = value
|
|
44
|
+
return response
|
|
45
|
+
|
|
46
|
+
return RateLimitMiddleware
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .extractors import (
|
|
2
|
+
ip_extractor,
|
|
3
|
+
api_key_extractor,
|
|
4
|
+
user_id_extractor,
|
|
5
|
+
custom_extractor,
|
|
6
|
+
)
|
|
7
|
+
from .dependencies import rate_limiter
|
|
8
|
+
from .middleware import RateLimitMiddleware
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ip_extractor",
|
|
12
|
+
"api_key_extractor",
|
|
13
|
+
"user_id_extractor",
|
|
14
|
+
"custom_extractor",
|
|
15
|
+
"rate_limiter",
|
|
16
|
+
"RateLimitMiddleware",
|
|
17
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Union, List, Callable
|
|
2
|
+
from fastapi import Request, HTTPException
|
|
3
|
+
from pycurb.core import AsyncRateLimiter, RateLimitResult
|
|
4
|
+
from .extractors import ip_extractor
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def rate_limiter(
|
|
9
|
+
limiter: AsyncRateLimiter,
|
|
10
|
+
rule_name: Union[str, List[str]],
|
|
11
|
+
key_extractor: Callable[[Request], str] = ip_extractor,
|
|
12
|
+
):
|
|
13
|
+
"""
|
|
14
|
+
Factory that creates a FastAPI dependency for rate limiting.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
async def dependency(request: Request) -> RateLimitResult:
|
|
18
|
+
key = key_extractor(request)
|
|
19
|
+
result = await limiter.check(key, rule_name)
|
|
20
|
+
if not result.allowed:
|
|
21
|
+
retry_after = max(1, int(result.reset_at - time.time()))
|
|
22
|
+
raise HTTPException(
|
|
23
|
+
status_code=429,
|
|
24
|
+
detail="Rate limit exceeded",
|
|
25
|
+
headers={"Retry-After": str(retry_after)},
|
|
26
|
+
)
|
|
27
|
+
request.state.rate_limit_result = result
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
return dependency
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def ip_extractor(request: Request) -> str:
|
|
5
|
+
"""Extract client IP address, respecting X-Forwarded-For."""
|
|
6
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
7
|
+
if forwarded:
|
|
8
|
+
return forwarded.split(",")[0].strip()
|
|
9
|
+
return request.client.host if request.client else "unknown"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def api_key_extractor(request: Request, header_name: str = "X-API-Key") -> str:
|
|
13
|
+
"""Extract API key from a header."""
|
|
14
|
+
return request.headers.get(header_name, "")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def user_id_extractor(request: Request) -> str:
|
|
18
|
+
"""Extract user ID from request state (set by auth middleware)."""
|
|
19
|
+
return getattr(request.state, "user_id", "anon")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def custom_extractor(extractor_func):
|
|
23
|
+
"""Decorator to register custom extractors (optional)."""
|
|
24
|
+
return extractor_func
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
4
|
+
from starlette.types import ASGIApp
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
from pycurb.core import AsyncRateLimiter
|
|
7
|
+
from pycurb.core.models import RateLimitHeaders
|
|
8
|
+
from .extractors import ip_extractor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
12
|
+
"""
|
|
13
|
+
ASGI middleware to apply a global rate limit rule to all requests.
|
|
14
|
+
Optionally exclude certain paths.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
app: ASGIApp,
|
|
20
|
+
limiter: AsyncRateLimiter,
|
|
21
|
+
rule_name: str,
|
|
22
|
+
key_extractor: Callable[[Request], str] = ip_extractor,
|
|
23
|
+
exclude_paths: Optional[list[str]] = None,
|
|
24
|
+
):
|
|
25
|
+
super().__init__(app)
|
|
26
|
+
self.limiter = limiter
|
|
27
|
+
self.rule_name = rule_name
|
|
28
|
+
self.key_extractor = key_extractor
|
|
29
|
+
self.exclude_paths = exclude_paths or []
|
|
30
|
+
|
|
31
|
+
async def dispatch(self, request: Request, call_next):
|
|
32
|
+
# Skip excluded paths
|
|
33
|
+
for path in self.exclude_paths:
|
|
34
|
+
if request.url.path.startswith(path):
|
|
35
|
+
return await call_next(request)
|
|
36
|
+
|
|
37
|
+
key = self.key_extractor(request)
|
|
38
|
+
result = await self.limiter.check(key, self.rule_name)
|
|
39
|
+
|
|
40
|
+
if not result.allowed:
|
|
41
|
+
headers = RateLimitHeaders.from_result(result)
|
|
42
|
+
return JSONResponse(
|
|
43
|
+
status_code=429,
|
|
44
|
+
content={"detail": "Rate limit exceeded"},
|
|
45
|
+
headers=headers.to_dict(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Add rate limit headers to response
|
|
49
|
+
response = await call_next(request)
|
|
50
|
+
headers = RateLimitHeaders.from_result(result)
|
|
51
|
+
for name, value in headers.to_dict().items():
|
|
52
|
+
response.headers[name] = value
|
|
53
|
+
return response
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .extractors import (
|
|
2
|
+
ip_extractor,
|
|
3
|
+
flask_login_user_extractor,
|
|
4
|
+
session_user_extractor,
|
|
5
|
+
api_key_extractor,
|
|
6
|
+
)
|
|
7
|
+
from .decorators import rate_limit
|
|
8
|
+
from .middleware import RateLimit
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ip_extractor",
|
|
12
|
+
"flask_login_user_extractor",
|
|
13
|
+
"session_user_extractor",
|
|
14
|
+
"api_key_extractor",
|
|
15
|
+
"rate_limit",
|
|
16
|
+
"RateLimit",
|
|
17
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from flask import make_response, jsonify
|
|
3
|
+
from typing import Union, Callable, List, Optional
|
|
4
|
+
from pycurb.core import RateLimiter
|
|
5
|
+
from pycurb.core.models import RateLimitHeaders
|
|
6
|
+
from .extractors import ip_extractor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rate_limit(
|
|
10
|
+
limiter: RateLimiter,
|
|
11
|
+
rule_name: Union[str, List[str]],
|
|
12
|
+
key_extractor: Callable[..., str] = ip_extractor,
|
|
13
|
+
on_limit: Optional[Callable] = None,
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Flask view decorator for rate limiting.
|
|
17
|
+
Works with sync views.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def decorator(f):
|
|
21
|
+
@functools.wraps(f)
|
|
22
|
+
def wrapper(*args, **kwargs):
|
|
23
|
+
key = key_extractor()
|
|
24
|
+
result = limiter.check(key, rule_name)
|
|
25
|
+
if not result.allowed:
|
|
26
|
+
if on_limit:
|
|
27
|
+
response = on_limit(result)
|
|
28
|
+
if response is not None:
|
|
29
|
+
return response
|
|
30
|
+
headers = RateLimitHeaders.from_result(result)
|
|
31
|
+
resp = make_response(jsonify({"detail": "Rate limit exceeded"}), 429)
|
|
32
|
+
for name, value in headers.to_dict().items():
|
|
33
|
+
resp.headers[name] = value
|
|
34
|
+
return resp
|
|
35
|
+
rv = f(*args, **kwargs)
|
|
36
|
+
resp = make_response(rv)
|
|
37
|
+
headers = RateLimitHeaders.from_result(result)
|
|
38
|
+
for name, value in headers.to_dict().items():
|
|
39
|
+
resp.headers[name] = value
|
|
40
|
+
return resp
|
|
41
|
+
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
return decorator
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from flask import request, session
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def ip_extractor():
|
|
5
|
+
"""Extract client IP from request, respecting X-Forwarded-For."""
|
|
6
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
7
|
+
if forwarded:
|
|
8
|
+
return forwarded.split(",")[0].strip()
|
|
9
|
+
return request.remote_addr or "unknown"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def flask_login_user_extractor():
|
|
13
|
+
"""Extract authenticated user ID (using flask_login)."""
|
|
14
|
+
try:
|
|
15
|
+
from flask_login import current_user
|
|
16
|
+
|
|
17
|
+
if current_user.is_authenticated:
|
|
18
|
+
return str(current_user.get_id())
|
|
19
|
+
except (ImportError, AttributeError):
|
|
20
|
+
pass
|
|
21
|
+
return "anon"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session_user_extractor():
|
|
25
|
+
"""Extract authenticated user ID directly from session."""
|
|
26
|
+
return session.get("user_id", "anon")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def api_key_extractor(header="X-API-Key"):
|
|
30
|
+
"""Extract API key from a custom header."""
|
|
31
|
+
return request.headers.get(header, "")
|
|
32
|
+
|
|
33
|
+
def custom_extractor(extractor_func):
|
|
34
|
+
"""Decorator to register custom extractors (optional)."""
|
|
35
|
+
return extractor_func
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from flask import make_response, jsonify, request
|
|
2
|
+
from typing import Optional, Union, List, Callable
|
|
3
|
+
from pycurb.core import RateLimiter
|
|
4
|
+
from pycurb.core.models import RateLimitHeaders
|
|
5
|
+
from .extractors import ip_extractor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RateLimit:
|
|
9
|
+
"""
|
|
10
|
+
Flask extension for global rate limiting.
|
|
11
|
+
Usage:
|
|
12
|
+
app = Flask(__name__)
|
|
13
|
+
limiter = RateLimiterSync(...)
|
|
14
|
+
RateLimit(app, limiter, rule_name="global")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
app,
|
|
20
|
+
limiter: RateLimiter,
|
|
21
|
+
rule_name: Union[str, List[str]],
|
|
22
|
+
key_extractor: Callable[..., str],
|
|
23
|
+
):
|
|
24
|
+
self.app = app
|
|
25
|
+
self.limiter = limiter
|
|
26
|
+
self.rule_name = rule_name
|
|
27
|
+
self.key_extractor = key_extractor or ip_extractor
|
|
28
|
+
if app is not None:
|
|
29
|
+
self.init_app(app)
|
|
30
|
+
|
|
31
|
+
def init_app(self, app):
|
|
32
|
+
app.before_request(self.before_request)
|
|
33
|
+
app.after_request(self.after_request)
|
|
34
|
+
app.extensions["ratelimit"] = self
|
|
35
|
+
|
|
36
|
+
def before_request(self):
|
|
37
|
+
key = self.key_extractor()
|
|
38
|
+
result = self.limiter.check(key, self.rule_name)
|
|
39
|
+
if not result.allowed:
|
|
40
|
+
headers = RateLimitHeaders.from_result(result)
|
|
41
|
+
resp = make_response(jsonify({"detail": "Rate limit exceeded"}), 429)
|
|
42
|
+
for name, value in headers.to_dict().items():
|
|
43
|
+
resp.headers[name] = value
|
|
44
|
+
return resp
|
|
45
|
+
# Store result in request context for after_request
|
|
46
|
+
request.environ["_rate_limit_result"] = result
|
|
47
|
+
|
|
48
|
+
def after_request(self, response):
|
|
49
|
+
result = request.environ.get("_rate_limit_result", None)
|
|
50
|
+
if result:
|
|
51
|
+
headers = RateLimitHeaders.from_result(result)
|
|
52
|
+
for name, value in headers.to_dict().items():
|
|
53
|
+
response.headers[name] = value
|
|
54
|
+
return response
|
pycurb/core/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .models import LimitRule, RateLimitResult, RateLimitHeaders, RateLimitExceeded
|
|
2
|
+
from .limiter_async import AsyncRateLimiter
|
|
3
|
+
from .limiter import RateLimiter
|
|
4
|
+
from .resolver import RuleResolver, AsyncRuleResolver
|
|
5
|
+
from .decorators import rate_limit, arg_extractor
|
|
6
|
+
from .storage import MemoryStorage, AsyncMemoryStorage, RedisStorage, AsyncRedisStorage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LimitRule",
|
|
11
|
+
"RateLimitResult",
|
|
12
|
+
"RateLimitHeaders",
|
|
13
|
+
"RateLimiter",
|
|
14
|
+
"AsyncRateLimiter",
|
|
15
|
+
"RuleResolver",
|
|
16
|
+
"AsyncRuleResolver",
|
|
17
|
+
"MemoryStorage",
|
|
18
|
+
"AsyncMemoryStorage",
|
|
19
|
+
"RedisStorage",
|
|
20
|
+
"AsyncRedisStorage",
|
|
21
|
+
"rate_limit",
|
|
22
|
+
"RateLimitExceeded",
|
|
23
|
+
"arg_extractor",
|
|
24
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .fixed_window_async import AsyncFixedWindowAlgorithm
|
|
2
|
+
from .fixed_window import FixedWindowAlgorithm
|
|
3
|
+
from .sliding_window_async import AsyncSlidingWindowAlgorithm
|
|
4
|
+
from .sliding_window import SlidingWindowAlgorithm
|
|
5
|
+
from .token_bucket_async import AsyncTokenBucketAlgorithm
|
|
6
|
+
from .token_bucket import TokenBucketAlgorithm
|
|
7
|
+
from .leaky_bucket_async import AsyncLeakyBucketAlgorithm
|
|
8
|
+
from .leaky_bucket import LeakyBucketAlgorithm
|
|
9
|
+
from .gcra import GcraAlgorithm
|
|
10
|
+
from .gcra_async import AsyncGcraAlgorithm
|
|
11
|
+
from .base_async import AsyncRateLimiterAlgorithm
|
|
12
|
+
from .base import RateLimiterAlgorithm
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from ..models import LimitRule, RateLimitResult
|
|
3
|
+
from ..storage import Storage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RateLimiterAlgorithm(ABC):
|
|
7
|
+
"""Abstract base for all rate limiting algorithms."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
|
|
11
|
+
"""
|
|
12
|
+
Evaluate rate limit for a given key and rule.
|
|
13
|
+
Args:
|
|
14
|
+
key: Unique client identifier (extracted by adapter) prefixed with rule name
|
|
15
|
+
rule: The limit rule applied
|
|
16
|
+
storage: Storage backend for atomic counters
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
RateLimitResult: Decision and metadata.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from ..models import LimitRule, RateLimitResult
|
|
3
|
+
from ..storage import AsyncStorage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AsyncRateLimiterAlgorithm(ABC):
|
|
7
|
+
"""Async abstract base for all rate limiting algorithms."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
async def check(
|
|
11
|
+
self, key: str, rule: LimitRule, storage: AsyncStorage
|
|
12
|
+
) -> RateLimitResult:
|
|
13
|
+
"""
|
|
14
|
+
Evaluate rate limit for a given key and rule.
|
|
15
|
+
Args:
|
|
16
|
+
key: Unique client identifier (extracted by adapter) prefixed with rule name
|
|
17
|
+
rule: The limit rule applied
|
|
18
|
+
storage: Storage backend for atomic counters
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
RateLimitResult: Decision and metadata.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import math
|
|
3
|
+
from .base import RateLimiterAlgorithm
|
|
4
|
+
from ..models import LimitRule, RateLimitResult
|
|
5
|
+
from ..storage import Storage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FixedWindowAlgorithm(RateLimiterAlgorithm):
|
|
9
|
+
def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
|
|
10
|
+
if rule.limit is None or rule.window is None:
|
|
11
|
+
raise ValueError("Fixed window algorithm requires 'limit' and 'window'.")
|
|
12
|
+
|
|
13
|
+
now = time.time()
|
|
14
|
+
storage_key = f"{rule.name}:{key}"
|
|
15
|
+
allowed, remaining, reset_at = storage.fixed_window(
|
|
16
|
+
key=storage_key, window=rule.window, limit=rule.limit, now=now
|
|
17
|
+
)
|
|
18
|
+
retry_after = max(0, math.ceil(reset_at - now))
|
|
19
|
+
return RateLimitResult(
|
|
20
|
+
allowed=allowed,
|
|
21
|
+
remaining=remaining,
|
|
22
|
+
reset_at=reset_at,
|
|
23
|
+
limit=rule.limit,
|
|
24
|
+
retry_after=retry_after if not allowed else None,
|
|
25
|
+
rule_name=rule.name,
|
|
26
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import math
|
|
3
|
+
from .base_async import AsyncRateLimiterAlgorithm
|
|
4
|
+
from ..models import LimitRule, RateLimitResult
|
|
5
|
+
from ..storage import AsyncStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncFixedWindowAlgorithm(AsyncRateLimiterAlgorithm):
|
|
9
|
+
async def check(
|
|
10
|
+
self, key: str, rule: LimitRule, storage: AsyncStorage
|
|
11
|
+
) -> RateLimitResult:
|
|
12
|
+
if rule.limit is None or rule.window is None:
|
|
13
|
+
raise ValueError("Fixed window algorithm requires 'limit' and 'window'.")
|
|
14
|
+
|
|
15
|
+
now = time.time()
|
|
16
|
+
storage_key = f"{rule.name}:{key}"
|
|
17
|
+
allowed, remaining, reset_at = await storage.fixed_window(
|
|
18
|
+
key=storage_key, window=rule.window, limit=rule.limit, now=now
|
|
19
|
+
)
|
|
20
|
+
retry_after = max(0, math.ceil(reset_at - now))
|
|
21
|
+
return RateLimitResult(
|
|
22
|
+
allowed=allowed,
|
|
23
|
+
remaining=remaining,
|
|
24
|
+
reset_at=reset_at,
|
|
25
|
+
limit=rule.limit,
|
|
26
|
+
retry_after=retry_after if not allowed else None,
|
|
27
|
+
rule_name=rule.name,
|
|
28
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import math
|
|
3
|
+
from .base import RateLimiterAlgorithm
|
|
4
|
+
from ..models import LimitRule, RateLimitResult
|
|
5
|
+
from ..storage import Storage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GcraAlgorithm(RateLimiterAlgorithm):
|
|
9
|
+
def check(self, key: str, rule: LimitRule, storage: Storage) -> RateLimitResult:
|
|
10
|
+
capacity = rule.capacity if rule.capacity is not None else rule.limit
|
|
11
|
+
if capacity is None:
|
|
12
|
+
raise ValueError("Gcra algorithm requires 'capacity' or 'limit'.")
|
|
13
|
+
|
|
14
|
+
if rule.refill_rate is not None:
|
|
15
|
+
rate = rule.refill_rate
|
|
16
|
+
else:
|
|
17
|
+
if rule.window is None:
|
|
18
|
+
raise ValueError("Gcra algorithm requires rate equal to 'refill_rate'")
|
|
19
|
+
rate = capacity / rule.window
|
|
20
|
+
|
|
21
|
+
if rate <= 0:
|
|
22
|
+
raise ValueError(f"rate must be positive, got {rate}")
|
|
23
|
+
|
|
24
|
+
now = time.time()
|
|
25
|
+
storage_key = f"{rule.name}:{key}"
|
|
26
|
+
allowed, remaining, reset_at = storage.gcra(
|
|
27
|
+
key=storage_key, capacity=capacity, rate=rate, now=now
|
|
28
|
+
)
|
|
29
|
+
retry_after = max(0, math.ceil(reset_at - now))
|
|
30
|
+
|
|
31
|
+
return RateLimitResult(
|
|
32
|
+
allowed=allowed,
|
|
33
|
+
remaining=remaining,
|
|
34
|
+
reset_at=reset_at,
|
|
35
|
+
limit=capacity,
|
|
36
|
+
retry_after=retry_after if not allowed else None,
|
|
37
|
+
rule_name=rule.name,
|
|
38
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import math
|
|
3
|
+
from .base_async import AsyncRateLimiterAlgorithm
|
|
4
|
+
from ..models import LimitRule, RateLimitResult
|
|
5
|
+
from ..storage import AsyncStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AsyncGcraAlgorithm(AsyncRateLimiterAlgorithm):
|
|
9
|
+
async def check(
|
|
10
|
+
self, key: str, rule: LimitRule, storage: AsyncStorage
|
|
11
|
+
) -> RateLimitResult:
|
|
12
|
+
capacity = rule.capacity if rule.capacity is not None else rule.limit
|
|
13
|
+
if capacity is None:
|
|
14
|
+
raise ValueError("Gcra algorithm requires 'capacity' or 'limit'.")
|
|
15
|
+
|
|
16
|
+
if rule.refill_rate is not None:
|
|
17
|
+
rate = rule.refill_rate
|
|
18
|
+
else:
|
|
19
|
+
if rule.window is None:
|
|
20
|
+
raise ValueError("Gcra algorithm requires rate equal to 'refill_rate'")
|
|
21
|
+
rate = capacity / rule.window
|
|
22
|
+
|
|
23
|
+
if rate <= 0:
|
|
24
|
+
raise ValueError(f"rate must be positive, got {rate}")
|
|
25
|
+
|
|
26
|
+
now = time.time()
|
|
27
|
+
storage_key = f"{rule.name}:{key}"
|
|
28
|
+
allowed, remaining, reset_at = await storage.gcra(
|
|
29
|
+
key=storage_key, capacity=capacity, rate=rate, now=now
|
|
30
|
+
)
|
|
31
|
+
retry_after = max(0, math.ceil(reset_at - now))
|
|
32
|
+
|
|
33
|
+
return RateLimitResult(
|
|
34
|
+
allowed=allowed,
|
|
35
|
+
remaining=remaining,
|
|
36
|
+
reset_at=reset_at,
|
|
37
|
+
limit=capacity,
|
|
38
|
+
retry_after=retry_after if not allowed else None,
|
|
39
|
+
rule_name=rule.name,
|
|
40
|
+
)
|