moderato 0.3.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.
- moderato/__init__.py +75 -0
- moderato/algorithms/__init__.py +49 -0
- moderato/algorithms/base.py +62 -0
- moderato/algorithms/sliding_window.py +292 -0
- moderato/algorithms/token_bucket.py +226 -0
- moderato/backends/__init__.py +7 -0
- moderato/backends/redis.py +735 -0
- moderato/decorators.py +419 -0
- moderato/exceptions.py +57 -0
- moderato/limiter.py +850 -0
- moderato/metrics.py +405 -0
- moderato/middleware.py +235 -0
- moderato/models.py +99 -0
- moderato/py.typed +0 -0
- moderato/scripts/fixed_window.lua +66 -0
- moderato/scripts/sliding_window.lua +157 -0
- moderato/scripts/token_bucket.lua +109 -0
- moderato/utils.py +218 -0
- moderato-0.3.0.dist-info/LICENSE +21 -0
- moderato-0.3.0.dist-info/METADATA +545 -0
- moderato-0.3.0.dist-info/RECORD +22 -0
- moderato-0.3.0.dist-info/WHEEL +4 -0
moderato/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Moderato - Async Redis-backed rate limiting for Python
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
A high-performance, Redis-backed rate limiting library with async support.
|
|
6
|
+
|
|
7
|
+
Basic usage:
|
|
8
|
+
>>> from moderato import RateLimiter
|
|
9
|
+
>>> limiter = RateLimiter(redis_url="redis://localhost:6379")
|
|
10
|
+
>>> await limiter.connect()
|
|
11
|
+
>>> await limiter.check(key="user:123", rate="100/minute")
|
|
12
|
+
|
|
13
|
+
FastAPI integration:
|
|
14
|
+
>>> from fastapi import FastAPI, Request
|
|
15
|
+
>>> from moderato import RateLimiter
|
|
16
|
+
>>>
|
|
17
|
+
>>> app = FastAPI()
|
|
18
|
+
>>> limiter = RateLimiter()
|
|
19
|
+
>>>
|
|
20
|
+
>>> @app.get("/api/data")
|
|
21
|
+
>>> @limiter.limit("100/minute")
|
|
22
|
+
>>> async def get_data(request: Request):
|
|
23
|
+
>>> return {"data": "..."}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from .exceptions import BackendError, RateLimitConfigError, RateLimitExceeded
|
|
27
|
+
from .limiter import RateLimiter
|
|
28
|
+
from .models import CheckResult, RateLimitConfig
|
|
29
|
+
|
|
30
|
+
# Framework integration (Starlette middleware) is optional - only import
|
|
31
|
+
# if starlette is available. Install with: pip install 'moderato[fastapi]'
|
|
32
|
+
try:
|
|
33
|
+
from .middleware import RateLimitHeadersMiddleware
|
|
34
|
+
|
|
35
|
+
_FRAMEWORK_AVAILABLE = True
|
|
36
|
+
except ModuleNotFoundError as exc:
|
|
37
|
+
# Hide only a missing starlette; a defect inside middleware.py or its
|
|
38
|
+
# other dependencies must surface instead of being swallowed.
|
|
39
|
+
if exc.name is None or not (exc.name == "starlette" or exc.name.startswith("starlette.")):
|
|
40
|
+
raise
|
|
41
|
+
_FRAMEWORK_AVAILABLE = False
|
|
42
|
+
RateLimitHeadersMiddleware = None # type: ignore[misc, assignment]
|
|
43
|
+
|
|
44
|
+
# Metrics are optional - only import if prometheus_client is available
|
|
45
|
+
try:
|
|
46
|
+
from .metrics import RateLimitMetrics as RateLimitMetrics
|
|
47
|
+
from .metrics import init_metrics as init_metrics
|
|
48
|
+
|
|
49
|
+
_METRICS_AVAILABLE = True
|
|
50
|
+
except ModuleNotFoundError as exc:
|
|
51
|
+
if exc.name is None or not (
|
|
52
|
+
exc.name == "prometheus_client" or exc.name.startswith("prometheus_client.")
|
|
53
|
+
):
|
|
54
|
+
raise
|
|
55
|
+
_METRICS_AVAILABLE = False
|
|
56
|
+
|
|
57
|
+
__version__ = "0.3.0"
|
|
58
|
+
__author__ = "Arjun Aravind"
|
|
59
|
+
__email__ = "arjunaravind748@gmail.com"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"RateLimiter",
|
|
63
|
+
"RateLimitExceeded",
|
|
64
|
+
"RateLimitConfigError",
|
|
65
|
+
"BackendError",
|
|
66
|
+
"RateLimitConfig",
|
|
67
|
+
"CheckResult",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
if _FRAMEWORK_AVAILABLE:
|
|
71
|
+
__all__.append("RateLimitHeadersMiddleware")
|
|
72
|
+
|
|
73
|
+
# Add metrics to exports if available
|
|
74
|
+
if _METRICS_AVAILABLE:
|
|
75
|
+
__all__.extend(["RateLimitMetrics", "init_metrics"])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rate limiting algorithms module.
|
|
3
|
+
|
|
4
|
+
DEPRECATION NOTICE:
|
|
5
|
+
These algorithm classes are provided for reference and educational purposes.
|
|
6
|
+
The main RateLimiter class uses the Redis backend directly and does not use
|
|
7
|
+
these classes. For production use, always use the RateLimiter class:
|
|
8
|
+
|
|
9
|
+
from moderato import RateLimiter
|
|
10
|
+
|
|
11
|
+
limiter = RateLimiter(redis_url="redis://localhost:6379")
|
|
12
|
+
await limiter.check(key="user:123", rate="100/minute", algorithm="sliding_window")
|
|
13
|
+
|
|
14
|
+
The algorithm parameter in RateLimiter.check() accepts:
|
|
15
|
+
- "fixed_window" (default) - Simple fixed time windows
|
|
16
|
+
- "token_bucket" - Token bucket with smooth refill
|
|
17
|
+
- "sliding_window" - Weighted sliding window (most accurate)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import warnings
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .base import RateLimitAlgorithm
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _deprecated_import(name: str) -> None:
|
|
27
|
+
warnings.warn(
|
|
28
|
+
f"{name} algorithm class is deprecated and may be removed in a future version. "
|
|
29
|
+
f"Use RateLimiter.check(algorithm='{name.lower()}') instead.",
|
|
30
|
+
DeprecationWarning,
|
|
31
|
+
stacklevel=3,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __getattr__(name: str) -> Any:
|
|
36
|
+
if name == "TokenBucket":
|
|
37
|
+
_deprecated_import("TokenBucket")
|
|
38
|
+
from .token_bucket import TokenBucket
|
|
39
|
+
|
|
40
|
+
return TokenBucket
|
|
41
|
+
elif name == "SlidingWindow":
|
|
42
|
+
_deprecated_import("SlidingWindow")
|
|
43
|
+
from .sliding_window import SlidingWindow
|
|
44
|
+
|
|
45
|
+
return SlidingWindow
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["RateLimitAlgorithm", "TokenBucket", "SlidingWindow"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for rate limiting algorithms.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any, NamedTuple, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimitResult(NamedTuple):
|
|
10
|
+
"""Result of a rate limit check."""
|
|
11
|
+
|
|
12
|
+
allowed: bool # Whether the request is allowed
|
|
13
|
+
remaining: int # Number of requests remaining (with multiplier)
|
|
14
|
+
retry_after: int # Milliseconds until rate limit resets
|
|
15
|
+
reset_at: Optional[int] = None # Unix timestamp when limit resets
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitAlgorithm(ABC):
|
|
19
|
+
"""Abstract base class for rate limiting algorithms."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def check(self, key: str, max_requests: int, window_seconds: int) -> RateLimitResult:
|
|
23
|
+
"""
|
|
24
|
+
Check if a request is allowed under the rate limit.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
key: Unique identifier for the rate limit
|
|
28
|
+
max_requests: Maximum allowed requests (with multiplier)
|
|
29
|
+
window_seconds: Time window in seconds
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
RateLimitResult with status and metadata
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def reset(self, key: str) -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Reset the rate limit for a specific key.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
key: Unique identifier for the rate limit
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if reset was successful
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Get current usage statistics for a key.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key: Unique identifier for the rate limit
|
|
56
|
+
*args: Additional positional arguments (algorithm-specific)
|
|
57
|
+
**kwargs: Additional keyword arguments (algorithm-specific)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dictionary with usage statistics
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sliding Window rate limiting algorithm implementation.
|
|
3
|
+
|
|
4
|
+
The sliding window algorithm provides the most accurate rate limiting
|
|
5
|
+
by combining the current window with a weighted portion of the previous window.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..backends.redis import RedisBackend
|
|
14
|
+
|
|
15
|
+
from .base import RateLimitAlgorithm, RateLimitResult
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlidingWindow(RateLimitAlgorithm):
|
|
21
|
+
"""
|
|
22
|
+
Sliding Window algorithm implementation.
|
|
23
|
+
|
|
24
|
+
How it works:
|
|
25
|
+
- Maintains counts for current and previous time windows
|
|
26
|
+
- Calculates weighted average based on position in current window
|
|
27
|
+
- Weight = (1 - progress_through_window)
|
|
28
|
+
- Example: 30s into 60s window = 50% from previous + 50% from current
|
|
29
|
+
|
|
30
|
+
Mathematical Formula:
|
|
31
|
+
weighted_count = previous_count * (1 - t/T) + current_count
|
|
32
|
+
where:
|
|
33
|
+
- t = time elapsed in current window
|
|
34
|
+
- T = total window duration
|
|
35
|
+
- previous_count = requests in previous window
|
|
36
|
+
- current_count = requests in current window
|
|
37
|
+
|
|
38
|
+
Example (100 requests/minute):
|
|
39
|
+
Time: 14:35:30 (30 seconds into minute)
|
|
40
|
+
Previous window (14:34): 80 requests
|
|
41
|
+
Current window (14:35): 40 requests
|
|
42
|
+
|
|
43
|
+
Weight for previous = 1 - (30/60) = 0.5
|
|
44
|
+
Weighted count = 80 * 0.5 + 40 = 40 + 40 = 80 requests
|
|
45
|
+
|
|
46
|
+
Can accept: 100 - 80 = 20 more requests
|
|
47
|
+
|
|
48
|
+
Advantages over Fixed Window:
|
|
49
|
+
- No boundary bursts (smooths across window edges)
|
|
50
|
+
- More accurate distribution
|
|
51
|
+
- Better user experience
|
|
52
|
+
- Fairer rate limiting
|
|
53
|
+
|
|
54
|
+
Advantages over Token Bucket:
|
|
55
|
+
- Simpler to understand
|
|
56
|
+
- More predictable
|
|
57
|
+
- Lower memory usage
|
|
58
|
+
- Easier to debug
|
|
59
|
+
|
|
60
|
+
Disadvantages:
|
|
61
|
+
- Requires two Redis keys (current + previous)
|
|
62
|
+
- Slightly more complex than Fixed Window
|
|
63
|
+
- Not as smooth as Token Bucket for bursts
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, backend: "RedisBackend") -> None:
|
|
67
|
+
"""
|
|
68
|
+
Initialize Sliding Window algorithm.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
backend: Redis backend for executing Lua scripts
|
|
72
|
+
"""
|
|
73
|
+
self.backend = backend
|
|
74
|
+
logger.debug("Initialized SlidingWindow algorithm")
|
|
75
|
+
|
|
76
|
+
async def check(
|
|
77
|
+
self,
|
|
78
|
+
key: str,
|
|
79
|
+
max_requests: int,
|
|
80
|
+
window_seconds: int,
|
|
81
|
+
cost: int = 1000,
|
|
82
|
+
) -> RateLimitResult:
|
|
83
|
+
"""
|
|
84
|
+
Check if a request is allowed under sliding window rate limit.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
key: Base identifier for the rate limit (without time suffix)
|
|
88
|
+
max_requests: Maximum requests (with multiplier)
|
|
89
|
+
window_seconds: Time window in seconds
|
|
90
|
+
cost: Number of requests to consume (with multiplier)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
RateLimitResult with allowed status and metadata
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
For "100/minute":
|
|
97
|
+
- max_requests = 100000 (100 * 1000)
|
|
98
|
+
- window_seconds = 60
|
|
99
|
+
- At 14:35:30:
|
|
100
|
+
- Current window: ratelimit:user:default:14:35
|
|
101
|
+
- Previous window: ratelimit:user:default:14:34
|
|
102
|
+
- Weight: 0.5 (30 seconds into current window)
|
|
103
|
+
"""
|
|
104
|
+
# Get current timestamp
|
|
105
|
+
current_time = int(time.time())
|
|
106
|
+
|
|
107
|
+
# Calculate current window start time
|
|
108
|
+
window_start = current_time - (current_time % window_seconds)
|
|
109
|
+
|
|
110
|
+
# Calculate previous window start time
|
|
111
|
+
previous_window_start = window_start - window_seconds
|
|
112
|
+
|
|
113
|
+
# Generate keys for current and previous windows
|
|
114
|
+
# We'll append the window start timestamp to ensure uniqueness
|
|
115
|
+
current_key = f"{key}:{window_start}"
|
|
116
|
+
previous_key = f"{key}:{previous_window_start}"
|
|
117
|
+
|
|
118
|
+
# Execute sliding window Lua script
|
|
119
|
+
result = await self.backend.check_sliding_window(
|
|
120
|
+
current_key=current_key,
|
|
121
|
+
previous_key=previous_key,
|
|
122
|
+
max_requests=max_requests,
|
|
123
|
+
window_seconds=window_seconds,
|
|
124
|
+
current_time=current_time,
|
|
125
|
+
cost=cost,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Calculate reset timestamp (start of next window)
|
|
129
|
+
reset_at = window_start + window_seconds
|
|
130
|
+
|
|
131
|
+
return RateLimitResult(
|
|
132
|
+
allowed=result.allowed,
|
|
133
|
+
remaining=result.remaining,
|
|
134
|
+
retry_after=result.retry_after,
|
|
135
|
+
reset_at=reset_at,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def reset(self, key: str) -> bool:
|
|
139
|
+
"""
|
|
140
|
+
Reset sliding window for a specific key.
|
|
141
|
+
|
|
142
|
+
This removes both current and previous window data.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
key: Base identifier for the rate limit
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
True if reset was successful
|
|
149
|
+
"""
|
|
150
|
+
# For sliding window, we need to reset multiple keys
|
|
151
|
+
# We'll try to delete keys for recent windows
|
|
152
|
+
current_time = int(time.time())
|
|
153
|
+
|
|
154
|
+
# Try to delete current and recent windows
|
|
155
|
+
success = False
|
|
156
|
+
for offset in [0, 60, 3600, 86400]: # Now, minute, hour, day ago
|
|
157
|
+
window_start = current_time - (current_time % offset) if offset > 0 else current_time
|
|
158
|
+
test_key = f"{key}:{window_start}"
|
|
159
|
+
if await self.backend.reset(test_key):
|
|
160
|
+
success = True
|
|
161
|
+
|
|
162
|
+
# Also try previous window
|
|
163
|
+
if offset > 0:
|
|
164
|
+
prev_key = f"{key}:{window_start - offset}"
|
|
165
|
+
if await self.backend.reset(prev_key):
|
|
166
|
+
success = True
|
|
167
|
+
|
|
168
|
+
return success
|
|
169
|
+
|
|
170
|
+
async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
171
|
+
"""
|
|
172
|
+
Get current usage statistics for sliding window.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
key: Base identifier for the rate limit
|
|
176
|
+
*args: Expected to contain max_requests and window_seconds
|
|
177
|
+
**kwargs: Not used
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Dictionary with:
|
|
181
|
+
- current: Weighted current usage (without multiplier)
|
|
182
|
+
- limit: Maximum requests (without multiplier)
|
|
183
|
+
- remaining: Requests remaining (without multiplier)
|
|
184
|
+
- current_window: Requests in current window
|
|
185
|
+
- previous_window: Requests in previous window
|
|
186
|
+
- weight: Weight applied to previous window (0.0 to 1.0)
|
|
187
|
+
"""
|
|
188
|
+
max_requests: int = args[0] if len(args) > 0 else kwargs.get("max_requests", 0)
|
|
189
|
+
window_seconds: int = args[1] if len(args) > 1 else kwargs.get("window_seconds", 60)
|
|
190
|
+
current_time = int(time.time())
|
|
191
|
+
window_start = current_time - (current_time % window_seconds)
|
|
192
|
+
previous_window_start = window_start - window_seconds
|
|
193
|
+
|
|
194
|
+
current_key = f"{key}:{window_start}"
|
|
195
|
+
previous_key = f"{key}:{previous_window_start}"
|
|
196
|
+
|
|
197
|
+
# Get counts from Redis
|
|
198
|
+
current_usage = await self.backend.get_usage(current_key)
|
|
199
|
+
previous_usage = await self.backend.get_usage(previous_key)
|
|
200
|
+
|
|
201
|
+
current_count = current_usage.get("current", 0)
|
|
202
|
+
previous_count = previous_usage.get("current", 0)
|
|
203
|
+
|
|
204
|
+
# Calculate weight using integer math (consistent with Lua script)
|
|
205
|
+
elapsed_in_window = current_time - window_start
|
|
206
|
+
remaining_in_window = window_seconds - elapsed_in_window
|
|
207
|
+
|
|
208
|
+
# Use fixed-point weight (0-1000 scale) for consistency with Lua
|
|
209
|
+
prev_weight_fp = (remaining_in_window * 1000) // window_seconds if window_seconds > 0 else 0
|
|
210
|
+
|
|
211
|
+
# Calculate weighted count using integer math
|
|
212
|
+
# Formula: weighted = current + (previous * weight)
|
|
213
|
+
# Note: counts already have 1000x multiplier, weight is 0-1000
|
|
214
|
+
weighted_previous = (previous_count * prev_weight_fp) // 1000
|
|
215
|
+
weighted_count = (current_count + weighted_previous) // 1000 # Divide by 1000 for display
|
|
216
|
+
|
|
217
|
+
max_requests_display = max_requests // 1000
|
|
218
|
+
remaining = max(0, max_requests_display - weighted_count)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"current": weighted_count,
|
|
222
|
+
"limit": max_requests_display,
|
|
223
|
+
"remaining": remaining,
|
|
224
|
+
"current_window": current_count // 1000,
|
|
225
|
+
"previous_window": previous_count // 1000,
|
|
226
|
+
"weight": prev_weight_fp / 1000, # Convert fixed-point to float for display
|
|
227
|
+
"window_seconds": window_seconds,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def calculate_sliding_window_count(
|
|
232
|
+
current_count: int,
|
|
233
|
+
previous_count: int,
|
|
234
|
+
window_seconds: int,
|
|
235
|
+
elapsed_seconds: int,
|
|
236
|
+
) -> float:
|
|
237
|
+
"""
|
|
238
|
+
Calculate weighted count for sliding window.
|
|
239
|
+
|
|
240
|
+
This is the core formula used by the sliding window algorithm.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
current_count: Requests in current window
|
|
244
|
+
previous_count: Requests in previous window
|
|
245
|
+
window_seconds: Total window duration
|
|
246
|
+
elapsed_seconds: Time elapsed in current window
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Weighted count as float
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
>>> # 30 seconds into 60-second window
|
|
253
|
+
>>> calculate_sliding_window_count(
|
|
254
|
+
... current_count=40,
|
|
255
|
+
... previous_count=80,
|
|
256
|
+
... window_seconds=60,
|
|
257
|
+
... elapsed_seconds=30
|
|
258
|
+
... )
|
|
259
|
+
80.0 # 40 + (80 * 0.5)
|
|
260
|
+
|
|
261
|
+
>>> # Start of window (0 seconds elapsed)
|
|
262
|
+
>>> calculate_sliding_window_count(
|
|
263
|
+
... current_count=10,
|
|
264
|
+
... previous_count=90,
|
|
265
|
+
... window_seconds=60,
|
|
266
|
+
... elapsed_seconds=0
|
|
267
|
+
... )
|
|
268
|
+
100.0 # 10 + (90 * 1.0)
|
|
269
|
+
|
|
270
|
+
>>> # End of window (60 seconds elapsed)
|
|
271
|
+
>>> calculate_sliding_window_count(
|
|
272
|
+
... current_count=50,
|
|
273
|
+
... previous_count=100,
|
|
274
|
+
... window_seconds=60,
|
|
275
|
+
... elapsed_seconds=60
|
|
276
|
+
... )
|
|
277
|
+
50.0 # 50 + (100 * 0.0)
|
|
278
|
+
"""
|
|
279
|
+
if window_seconds <= 0:
|
|
280
|
+
raise ValueError("Window seconds must be positive")
|
|
281
|
+
|
|
282
|
+
if elapsed_seconds < 0 or elapsed_seconds > window_seconds:
|
|
283
|
+
raise ValueError(f"Elapsed seconds must be between 0 and {window_seconds}")
|
|
284
|
+
|
|
285
|
+
# Calculate weight for previous window
|
|
286
|
+
progress = elapsed_seconds / window_seconds
|
|
287
|
+
previous_weight = 1 - progress
|
|
288
|
+
|
|
289
|
+
# Apply weighted formula
|
|
290
|
+
weighted_count = current_count + (previous_count * previous_weight)
|
|
291
|
+
|
|
292
|
+
return weighted_count
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token Bucket rate limiting algorithm implementation.
|
|
3
|
+
|
|
4
|
+
The token bucket algorithm provides smoother rate limiting compared to
|
|
5
|
+
fixed window, with better handling of bursty traffic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..backends.redis import RedisBackend
|
|
14
|
+
|
|
15
|
+
from .base import RateLimitAlgorithm, RateLimitResult
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TokenBucket(RateLimitAlgorithm):
|
|
21
|
+
"""
|
|
22
|
+
Token Bucket algorithm implementation.
|
|
23
|
+
|
|
24
|
+
How it works:
|
|
25
|
+
- A bucket holds tokens up to a maximum capacity
|
|
26
|
+
- Tokens are continuously added at a fixed refill rate
|
|
27
|
+
- Each request consumes one or more tokens
|
|
28
|
+
- If not enough tokens, request is denied
|
|
29
|
+
- Provides smooth rate limiting without boundary bursts
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
100 requests/minute = 100 max tokens, ~1.67 refill rate/second
|
|
33
|
+
Allows bursts up to 100 requests, then sustained 1.67 req/sec
|
|
34
|
+
|
|
35
|
+
Advantages over Fixed Window:
|
|
36
|
+
- No burst at window boundaries
|
|
37
|
+
- Smoother traffic distribution
|
|
38
|
+
- Better for bursty workloads
|
|
39
|
+
- Allows controlled bursts within capacity
|
|
40
|
+
|
|
41
|
+
Disadvantages:
|
|
42
|
+
- Slightly more complex
|
|
43
|
+
- Uses more Redis memory (stores tokens + timestamp)
|
|
44
|
+
- Can allow more requests in first window
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, backend: "RedisBackend") -> None:
|
|
48
|
+
"""
|
|
49
|
+
Initialize Token Bucket algorithm.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
backend: Redis backend for executing Lua scripts
|
|
53
|
+
"""
|
|
54
|
+
self.backend = backend
|
|
55
|
+
logger.debug("Initialized TokenBucket algorithm")
|
|
56
|
+
|
|
57
|
+
async def check(
|
|
58
|
+
self,
|
|
59
|
+
key: str,
|
|
60
|
+
max_requests: int,
|
|
61
|
+
window_seconds: int,
|
|
62
|
+
cost: int = 1000,
|
|
63
|
+
) -> RateLimitResult:
|
|
64
|
+
"""
|
|
65
|
+
Check if a request is allowed under token bucket rate limit.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
key: Unique identifier for the rate limit
|
|
69
|
+
max_requests: Maximum tokens (bucket capacity, with multiplier)
|
|
70
|
+
window_seconds: Time window to spread requests over
|
|
71
|
+
cost: Number of tokens to consume (with multiplier)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
RateLimitResult with allowed status and metadata
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
For "100/minute":
|
|
78
|
+
- max_requests = 100000 (100 * 1000)
|
|
79
|
+
- window_seconds = 60
|
|
80
|
+
- refill_rate = 100000 / 60 = 1666.67 tokens/sec
|
|
81
|
+
- Bucket starts full (100000 tokens)
|
|
82
|
+
- Refills at 1666.67 tokens/sec
|
|
83
|
+
- Max capacity: 100000 tokens
|
|
84
|
+
"""
|
|
85
|
+
# Preserve fractional refill rates so limits such as 1/hour can recover.
|
|
86
|
+
refill_rate_per_second = max_requests / window_seconds
|
|
87
|
+
|
|
88
|
+
# Get current timestamp in milliseconds
|
|
89
|
+
current_time_ms = int(time.time() * 1000)
|
|
90
|
+
|
|
91
|
+
# Execute token bucket Lua script
|
|
92
|
+
result = await self.backend.check_token_bucket(
|
|
93
|
+
key=key,
|
|
94
|
+
max_tokens=max_requests,
|
|
95
|
+
refill_rate_per_second=refill_rate_per_second,
|
|
96
|
+
window_seconds=window_seconds,
|
|
97
|
+
current_time_ms=current_time_ms,
|
|
98
|
+
cost=cost,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Calculate reset timestamp (when bucket would be full)
|
|
102
|
+
# If tokens remaining, no reset needed
|
|
103
|
+
# If denied, reset_at = current_time + retry_after
|
|
104
|
+
reset_at = None
|
|
105
|
+
if not result.allowed:
|
|
106
|
+
reset_at = (current_time_ms // 1000) + (result.retry_after // 1000)
|
|
107
|
+
|
|
108
|
+
return RateLimitResult(
|
|
109
|
+
allowed=result.allowed,
|
|
110
|
+
remaining=result.remaining,
|
|
111
|
+
retry_after=result.retry_after,
|
|
112
|
+
reset_at=reset_at,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def reset(self, key: str) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Reset token bucket for a specific key.
|
|
118
|
+
|
|
119
|
+
This removes the bucket data, causing the next request
|
|
120
|
+
to start with a full bucket.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
key: Unique identifier for the rate limit
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if reset was successful
|
|
127
|
+
"""
|
|
128
|
+
return await self.backend.reset(key)
|
|
129
|
+
|
|
130
|
+
async def get_usage(self, key: str, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Get current usage statistics for a token bucket.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
key: Unique identifier for the rate limit
|
|
136
|
+
*args: Expected to contain max_requests as first argument
|
|
137
|
+
**kwargs: Not used
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Dictionary with:
|
|
141
|
+
- current: Current token count (without multiplier)
|
|
142
|
+
- limit: Maximum tokens (without multiplier)
|
|
143
|
+
- remaining: Tokens remaining (without multiplier)
|
|
144
|
+
- last_refill: Unix timestamp of last refill
|
|
145
|
+
|
|
146
|
+
Note: For token bucket, "current" means tokens available,
|
|
147
|
+
not requests consumed (inverse of fixed window).
|
|
148
|
+
"""
|
|
149
|
+
max_requests: int = args[0] if args else kwargs.get("max_requests", 0)
|
|
150
|
+
usage = await self.backend.get_token_bucket_usage(key)
|
|
151
|
+
|
|
152
|
+
# Convert from integer math (divide by 1000)
|
|
153
|
+
current_tokens = usage.get("tokens", max_requests) // 1000
|
|
154
|
+
max_tokens_display = max_requests // 1000
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"current": current_tokens,
|
|
158
|
+
"limit": max_tokens_display,
|
|
159
|
+
"remaining": current_tokens,
|
|
160
|
+
"last_refill": usage.get("last_refill", 0),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def calculate_refill_rate(requests: int, window_seconds: int) -> float:
|
|
165
|
+
"""
|
|
166
|
+
Calculate token refill rate for token bucket.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
requests: Number of requests allowed in window
|
|
170
|
+
window_seconds: Time window in seconds
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Refill rate in tokens per second
|
|
174
|
+
|
|
175
|
+
Examples:
|
|
176
|
+
>>> calculate_refill_rate(100, 60) # 100/minute
|
|
177
|
+
1.6666666666666667
|
|
178
|
+
|
|
179
|
+
>>> calculate_refill_rate(1000, 3600) # 1000/hour
|
|
180
|
+
0.2777777777777778
|
|
181
|
+
|
|
182
|
+
>>> calculate_refill_rate(10, 1) # 10/second
|
|
183
|
+
10.0
|
|
184
|
+
"""
|
|
185
|
+
if window_seconds <= 0:
|
|
186
|
+
raise ValueError("Window seconds must be positive")
|
|
187
|
+
|
|
188
|
+
return requests / window_seconds
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def calculate_bucket_capacity(requests: int, burst_factor: float = 1.0) -> int:
|
|
192
|
+
"""
|
|
193
|
+
Calculate bucket capacity with optional burst allowance.
|
|
194
|
+
|
|
195
|
+
By default, bucket capacity equals the rate limit. You can
|
|
196
|
+
increase capacity to allow larger bursts.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
requests: Base number of requests allowed
|
|
200
|
+
burst_factor: Multiplier for bucket capacity (>= 1.0)
|
|
201
|
+
1.0 = no extra burst (default)
|
|
202
|
+
2.0 = allow 2x burst
|
|
203
|
+
0.5 = not recommended (less than rate)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Bucket capacity in tokens
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
>>> calculate_bucket_capacity(100, 1.0) # Standard
|
|
210
|
+
100
|
|
211
|
+
|
|
212
|
+
>>> calculate_bucket_capacity(100, 1.5) # Allow 50% burst
|
|
213
|
+
150
|
|
214
|
+
|
|
215
|
+
>>> calculate_bucket_capacity(100, 2.0) # Allow 2x burst
|
|
216
|
+
200
|
|
217
|
+
|
|
218
|
+
Note: Burst factor is not currently exposed in the main API,
|
|
219
|
+
but is available for advanced use cases.
|
|
220
|
+
"""
|
|
221
|
+
if burst_factor < 1.0:
|
|
222
|
+
logger.warning(
|
|
223
|
+
f"Burst factor {burst_factor} < 1.0 may cause issues. " "Consider using >= 1.0"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return int(requests * burst_factor)
|