limits 4.0.0__py3-none-any.whl → 4.1__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.
- limits/_version.py +3 -3
- limits/aio/storage/__init__.py +2 -1
- limits/aio/storage/base.py +70 -24
- limits/aio/storage/etcd.py +6 -2
- limits/aio/storage/memcached.py +157 -33
- limits/aio/storage/memory.py +98 -13
- limits/aio/storage/mongodb.py +233 -19
- limits/aio/storage/redis.py +100 -15
- limits/aio/strategies.py +122 -1
- limits/limits.py +10 -11
- limits/resources/redis/lua_scripts/acquire_sliding_window.lua +45 -0
- limits/resources/redis/lua_scripts/sliding_window.lua +17 -0
- limits/storage/__init__.py +7 -6
- limits/storage/base.py +92 -24
- limits/storage/etcd.py +6 -2
- limits/storage/memcached.py +142 -36
- limits/storage/memory.py +97 -12
- limits/storage/mongodb.py +220 -20
- limits/storage/redis.py +159 -138
- limits/storage/redis_cluster.py +3 -3
- limits/storage/redis_sentinel.py +12 -35
- limits/storage/registry.py +3 -3
- limits/strategies.py +119 -5
- limits/typing.py +43 -15
- limits/util.py +27 -18
- limits-4.1.dist-info/METADATA +268 -0
- limits-4.1.dist-info/RECORD +39 -0
- limits-4.0.0.dist-info/METADATA +0 -192
- limits-4.0.0.dist-info/RECORD +0 -37
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/LICENSE.txt +0 -0
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/WHEEL +0 -0
- {limits-4.0.0.dist-info → limits-4.1.dist-info}/top_level.txt +0 -0
limits/aio/storage/redis.py
CHANGED
|
@@ -5,9 +5,13 @@ from typing import TYPE_CHECKING, cast
|
|
|
5
5
|
from deprecated.sphinx import versionadded
|
|
6
6
|
from packaging.version import Version
|
|
7
7
|
|
|
8
|
-
from limits.aio.storage.base import
|
|
8
|
+
from limits.aio.storage.base import (
|
|
9
|
+
MovingWindowSupport,
|
|
10
|
+
SlidingWindowCounterSupport,
|
|
11
|
+
Storage,
|
|
12
|
+
)
|
|
9
13
|
from limits.errors import ConfigurationError
|
|
10
|
-
from limits.typing import AsyncRedisClient,
|
|
14
|
+
from limits.typing import AsyncRedisClient, Optional, Type, Union
|
|
11
15
|
from limits.util import get_package_data
|
|
12
16
|
|
|
13
17
|
if TYPE_CHECKING:
|
|
@@ -24,9 +28,15 @@ class RedisInteractor:
|
|
|
24
28
|
)
|
|
25
29
|
SCRIPT_CLEAR_KEYS = get_package_data(f"{RES_DIR}/clear_keys.lua")
|
|
26
30
|
SCRIPT_INCR_EXPIRE = get_package_data(f"{RES_DIR}/incr_expire.lua")
|
|
31
|
+
SCRIPT_SLIDING_WINDOW = get_package_data(f"{RES_DIR}/sliding_window.lua")
|
|
32
|
+
SCRIPT_ACQUIRE_SLIDING_WINDOW = get_package_data(
|
|
33
|
+
f"{RES_DIR}/acquire_sliding_window.lua"
|
|
34
|
+
)
|
|
27
35
|
|
|
28
36
|
lua_moving_window: "coredis.commands.Script[bytes]"
|
|
29
|
-
|
|
37
|
+
lua_acquire_moving_window: "coredis.commands.Script[bytes]"
|
|
38
|
+
lua_sliding_window: "coredis.commands.Script[bytes]"
|
|
39
|
+
lua_acquire_sliding_window: "coredis.commands.Script[bytes]"
|
|
30
40
|
lua_clear_keys: "coredis.commands.Script[bytes]"
|
|
31
41
|
lua_incr_expire: "coredis.commands.Script[bytes]"
|
|
32
42
|
|
|
@@ -78,14 +88,14 @@ class RedisInteractor:
|
|
|
78
88
|
|
|
79
89
|
async def get_moving_window(
|
|
80
90
|
self, key: str, limit: int, expiry: int
|
|
81
|
-
) ->
|
|
91
|
+
) -> tuple[float, int]:
|
|
82
92
|
"""
|
|
83
93
|
returns the starting point and the number of entries in the moving
|
|
84
94
|
window
|
|
85
95
|
|
|
86
96
|
:param key: rate limit key
|
|
87
97
|
:param expiry: expiry of entry
|
|
88
|
-
:return: (
|
|
98
|
+
:return: (previous count, previous TTL, current count, current TTL)
|
|
89
99
|
"""
|
|
90
100
|
key = self.prefixed_key(key)
|
|
91
101
|
timestamp = time.time()
|
|
@@ -96,6 +106,23 @@ class RedisInteractor:
|
|
|
96
106
|
return float(window[0]), window[1] # type: ignore
|
|
97
107
|
return timestamp, 0
|
|
98
108
|
|
|
109
|
+
async def get_sliding_window(
|
|
110
|
+
self, key: str, expiry: int
|
|
111
|
+
) -> tuple[int, float, int, float]:
|
|
112
|
+
previous_key = self.prefixed_key(self._previous_window_key(key))
|
|
113
|
+
current_key = self.prefixed_key(self._current_window_key(key))
|
|
114
|
+
|
|
115
|
+
if window := await self.lua_sliding_window.execute(
|
|
116
|
+
[previous_key, current_key], [expiry]
|
|
117
|
+
):
|
|
118
|
+
return (
|
|
119
|
+
int(window[0] or 0), # type: ignore
|
|
120
|
+
max(0, float(window[1] or 0)) / 1000, # type: ignore
|
|
121
|
+
int(window[2] or 0), # type: ignore
|
|
122
|
+
max(0, float(window[3] or 0)) / 1000, # type: ignore
|
|
123
|
+
)
|
|
124
|
+
return 0, 0.0, 0, 0.0
|
|
125
|
+
|
|
99
126
|
async def _acquire_entry(
|
|
100
127
|
self,
|
|
101
128
|
key: str,
|
|
@@ -112,12 +139,27 @@ class RedisInteractor:
|
|
|
112
139
|
"""
|
|
113
140
|
key = self.prefixed_key(key)
|
|
114
141
|
timestamp = time.time()
|
|
115
|
-
acquired = await self.
|
|
142
|
+
acquired = await self.lua_acquire_moving_window.execute(
|
|
116
143
|
[key], [timestamp, limit, expiry, amount]
|
|
117
144
|
)
|
|
118
145
|
|
|
119
146
|
return bool(acquired)
|
|
120
147
|
|
|
148
|
+
async def _acquire_sliding_window_entry(
|
|
149
|
+
self,
|
|
150
|
+
previous_key: str,
|
|
151
|
+
current_key: str,
|
|
152
|
+
limit: int,
|
|
153
|
+
expiry: int,
|
|
154
|
+
amount: int = 1,
|
|
155
|
+
) -> bool:
|
|
156
|
+
previous_key = self.prefixed_key(previous_key)
|
|
157
|
+
current_key = self.prefixed_key(current_key)
|
|
158
|
+
acquired = await self.lua_acquire_sliding_window.execute(
|
|
159
|
+
[previous_key, current_key], [limit, expiry, amount]
|
|
160
|
+
)
|
|
161
|
+
return bool(acquired)
|
|
162
|
+
|
|
121
163
|
async def _get_expiry(self, key: str, connection: AsyncRedisClient) -> float:
|
|
122
164
|
"""
|
|
123
165
|
:param key: the key to get the expiry for
|
|
@@ -140,9 +182,35 @@ class RedisInteractor:
|
|
|
140
182
|
except: # noqa
|
|
141
183
|
return False
|
|
142
184
|
|
|
185
|
+
def _current_window_key(self, key: str) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Return the current window's storage key (Sliding window strategy)
|
|
188
|
+
|
|
189
|
+
Contrary to other strategies that have one key per rate limit item,
|
|
190
|
+
this strategy has two keys per rate limit item than must be on the same machine.
|
|
191
|
+
To keep the current key and the previous key on the same Redis cluster node,
|
|
192
|
+
curly braces are added.
|
|
193
|
+
|
|
194
|
+
Eg: "{constructed_key}"
|
|
195
|
+
"""
|
|
196
|
+
return f"{{{key}}}"
|
|
197
|
+
|
|
198
|
+
def _previous_window_key(self, key: str) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Return the previous window's storage key (Sliding window strategy).
|
|
201
|
+
|
|
202
|
+
Curvy braces are added on the common pattern with the current window's key,
|
|
203
|
+
so the current and the previous key are stored on the same Redis cluster node.
|
|
204
|
+
|
|
205
|
+
Eg: "{constructed_key}/-1"
|
|
206
|
+
"""
|
|
207
|
+
return f"{self._current_window_key(key)}/-1"
|
|
208
|
+
|
|
143
209
|
|
|
144
210
|
@versionadded(version="2.1")
|
|
145
|
-
class RedisStorage(
|
|
211
|
+
class RedisStorage(
|
|
212
|
+
RedisInteractor, Storage, MovingWindowSupport, SlidingWindowCounterSupport
|
|
213
|
+
):
|
|
146
214
|
"""
|
|
147
215
|
Rate limit storage with redis as backend.
|
|
148
216
|
|
|
@@ -200,18 +268,22 @@ class RedisStorage(RedisInteractor, Storage, MovingWindowSupport):
|
|
|
200
268
|
@property
|
|
201
269
|
def base_exceptions(
|
|
202
270
|
self,
|
|
203
|
-
) -> Union[Type[Exception],
|
|
271
|
+
) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
|
|
204
272
|
return self.dependency.exceptions.RedisError # type: ignore[no-any-return]
|
|
205
273
|
|
|
206
274
|
def initialize_storage(self, _uri: str) -> None:
|
|
207
275
|
# all these methods are coroutines, so must be called with await
|
|
208
276
|
self.lua_moving_window = self.storage.register_script(self.SCRIPT_MOVING_WINDOW)
|
|
209
|
-
self.
|
|
277
|
+
self.lua_acquire_moving_window = self.storage.register_script(
|
|
210
278
|
self.SCRIPT_ACQUIRE_MOVING_WINDOW
|
|
211
279
|
)
|
|
212
280
|
self.lua_clear_keys = self.storage.register_script(self.SCRIPT_CLEAR_KEYS)
|
|
213
|
-
self.lua_incr_expire = self.storage.register_script(
|
|
214
|
-
|
|
281
|
+
self.lua_incr_expire = self.storage.register_script(self.SCRIPT_INCR_EXPIRE)
|
|
282
|
+
self.lua_sliding_window = self.storage.register_script(
|
|
283
|
+
self.SCRIPT_SLIDING_WINDOW
|
|
284
|
+
)
|
|
285
|
+
self.lua_acquire_sliding_window = self.storage.register_script(
|
|
286
|
+
self.SCRIPT_ACQUIRE_SLIDING_WINDOW
|
|
215
287
|
)
|
|
216
288
|
|
|
217
289
|
async def incr(
|
|
@@ -261,6 +333,19 @@ class RedisStorage(RedisInteractor, Storage, MovingWindowSupport):
|
|
|
261
333
|
|
|
262
334
|
return await super()._acquire_entry(key, limit, expiry, self.storage, amount)
|
|
263
335
|
|
|
336
|
+
async def acquire_sliding_window_entry(
|
|
337
|
+
self,
|
|
338
|
+
key: str,
|
|
339
|
+
limit: int,
|
|
340
|
+
expiry: int,
|
|
341
|
+
amount: int = 1,
|
|
342
|
+
) -> bool:
|
|
343
|
+
current_key = self._current_window_key(key)
|
|
344
|
+
previous_key = self._previous_window_key(key)
|
|
345
|
+
return await super()._acquire_sliding_window_entry(
|
|
346
|
+
previous_key, current_key, limit, expiry, amount
|
|
347
|
+
)
|
|
348
|
+
|
|
264
349
|
async def get_expiry(self, key: str) -> float:
|
|
265
350
|
"""
|
|
266
351
|
:param key: the key to get the expiry for
|
|
@@ -302,7 +387,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
302
387
|
The storage schemes for redis cluster to be used in an async context
|
|
303
388
|
"""
|
|
304
389
|
|
|
305
|
-
DEFAULT_OPTIONS:
|
|
390
|
+
DEFAULT_OPTIONS: dict[str, Union[float, str, bool]] = {
|
|
306
391
|
"max_connections": 1000,
|
|
307
392
|
}
|
|
308
393
|
"Default options passed to :class:`coredis.RedisCluster`"
|
|
@@ -322,7 +407,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
322
407
|
available or if the redis host cannot be pinged.
|
|
323
408
|
"""
|
|
324
409
|
parsed = urllib.parse.urlparse(uri)
|
|
325
|
-
parsed_auth:
|
|
410
|
+
parsed_auth: dict[str, Union[float, str, bool]] = {}
|
|
326
411
|
|
|
327
412
|
if parsed.username:
|
|
328
413
|
parsed_auth["username"] = parsed.username
|
|
@@ -386,7 +471,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
386
471
|
uri: str,
|
|
387
472
|
service_name: Optional[str] = None,
|
|
388
473
|
use_replicas: bool = True,
|
|
389
|
-
sentinel_kwargs: Optional[
|
|
474
|
+
sentinel_kwargs: Optional[dict[str, Union[float, str, bool]]] = None,
|
|
390
475
|
**options: Union[float, str, bool],
|
|
391
476
|
):
|
|
392
477
|
"""
|
|
@@ -407,7 +492,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
407
492
|
sentinel_configuration = []
|
|
408
493
|
connection_options = options.copy()
|
|
409
494
|
sentinel_options = sentinel_kwargs.copy() if sentinel_kwargs else {}
|
|
410
|
-
parsed_auth:
|
|
495
|
+
parsed_auth: dict[str, Union[float, str, bool]] = {}
|
|
411
496
|
|
|
412
497
|
if parsed.username:
|
|
413
498
|
parsed_auth["username"] = parsed.username
|
limits/aio/strategies.py
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
Asynchronous rate limiting strategies
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import time
|
|
5
6
|
from abc import ABC, abstractmethod
|
|
6
|
-
from
|
|
7
|
+
from math import floor, inf
|
|
8
|
+
|
|
9
|
+
from deprecated.sphinx import deprecated, versionadded
|
|
7
10
|
|
|
8
11
|
from ..limits import RateLimitItem
|
|
9
12
|
from ..storage import StorageTypes
|
|
13
|
+
from ..typing import cast
|
|
10
14
|
from ..util import WindowStats
|
|
11
15
|
from .storage import MovingWindowSupport, Storage
|
|
16
|
+
from .storage.base import SlidingWindowCounterSupport
|
|
12
17
|
|
|
13
18
|
|
|
14
19
|
class RateLimiter(ABC):
|
|
@@ -183,6 +188,121 @@ class FixedWindowRateLimiter(RateLimiter):
|
|
|
183
188
|
return WindowStats(reset, remaining)
|
|
184
189
|
|
|
185
190
|
|
|
191
|
+
@versionadded(version="4.1")
|
|
192
|
+
class SlidingWindowCounterRateLimiter(RateLimiter):
|
|
193
|
+
"""
|
|
194
|
+
Reference: :ref:`strategies:sliding window counter`
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, storage: StorageTypes):
|
|
198
|
+
if not hasattr(storage, "get_sliding_window") or not hasattr(
|
|
199
|
+
storage, "acquire_sliding_window_entry"
|
|
200
|
+
):
|
|
201
|
+
raise NotImplementedError(
|
|
202
|
+
"SlidingWindowCounterRateLimiting is not implemented for storage "
|
|
203
|
+
"of type %s" % storage.__class__
|
|
204
|
+
)
|
|
205
|
+
super().__init__(storage)
|
|
206
|
+
|
|
207
|
+
def _weighted_count(
|
|
208
|
+
self,
|
|
209
|
+
item: RateLimitItem,
|
|
210
|
+
previous_count: int,
|
|
211
|
+
previous_expires_in: float,
|
|
212
|
+
current_count: int,
|
|
213
|
+
) -> float:
|
|
214
|
+
"""
|
|
215
|
+
Return the approximated by weighting the previous window count and adding the current window count.
|
|
216
|
+
"""
|
|
217
|
+
return previous_count * previous_expires_in / item.get_expiry() + current_count
|
|
218
|
+
|
|
219
|
+
async def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Consume the rate limit
|
|
222
|
+
|
|
223
|
+
:param item: The rate limit item
|
|
224
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
225
|
+
instance of the limit
|
|
226
|
+
:param cost: The cost of this hit, default 1
|
|
227
|
+
"""
|
|
228
|
+
return await cast(
|
|
229
|
+
SlidingWindowCounterSupport, self.storage
|
|
230
|
+
).acquire_sliding_window_entry(
|
|
231
|
+
item.key_for(*identifiers),
|
|
232
|
+
item.amount,
|
|
233
|
+
item.get_expiry(),
|
|
234
|
+
cost,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def test(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Check if the rate limit can be consumed
|
|
240
|
+
|
|
241
|
+
:param item: The rate limit item
|
|
242
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
243
|
+
instance of the limit
|
|
244
|
+
:param cost: The expected cost to be consumed, default 1
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
previous_count, previous_expires_in, current_count, _ = await cast(
|
|
248
|
+
SlidingWindowCounterSupport, self.storage
|
|
249
|
+
).get_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
self._weighted_count(
|
|
253
|
+
item, previous_count, previous_expires_in, current_count
|
|
254
|
+
)
|
|
255
|
+
< item.amount - cost + 1
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def get_window_stats(
|
|
259
|
+
self, item: RateLimitItem, *identifiers: str
|
|
260
|
+
) -> WindowStats:
|
|
261
|
+
"""
|
|
262
|
+
Query the reset time and remaining amount for the limit.
|
|
263
|
+
|
|
264
|
+
:param item: The rate limit item
|
|
265
|
+
:param identifiers: variable list of strings to uniquely identify this
|
|
266
|
+
instance of the limit
|
|
267
|
+
:return: (reset time, remaining)
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
(
|
|
271
|
+
previous_count,
|
|
272
|
+
previous_expires_in,
|
|
273
|
+
current_count,
|
|
274
|
+
current_expires_in,
|
|
275
|
+
) = await cast(SlidingWindowCounterSupport, self.storage).get_sliding_window(
|
|
276
|
+
item.key_for(*identifiers), item.get_expiry()
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
remaining = max(
|
|
280
|
+
0,
|
|
281
|
+
item.amount
|
|
282
|
+
- floor(
|
|
283
|
+
self._weighted_count(
|
|
284
|
+
item, previous_count, previous_expires_in, current_count
|
|
285
|
+
)
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
now = time.time()
|
|
290
|
+
|
|
291
|
+
if not (previous_count or current_count):
|
|
292
|
+
return WindowStats(now, remaining)
|
|
293
|
+
|
|
294
|
+
expiry = item.get_expiry()
|
|
295
|
+
|
|
296
|
+
previous_reset_in, current_reset_in = inf, inf
|
|
297
|
+
if previous_count:
|
|
298
|
+
previous_reset_in = previous_expires_in % (expiry / previous_count)
|
|
299
|
+
if current_count:
|
|
300
|
+
current_reset_in = current_expires_in % expiry
|
|
301
|
+
|
|
302
|
+
return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@deprecated(version="4.1")
|
|
186
306
|
class FixedWindowElasticExpiryRateLimiter(FixedWindowRateLimiter):
|
|
187
307
|
"""
|
|
188
308
|
Reference: :ref:`strategies:fixed window with elastic expiry`
|
|
@@ -208,6 +328,7 @@ class FixedWindowElasticExpiryRateLimiter(FixedWindowRateLimiter):
|
|
|
208
328
|
|
|
209
329
|
|
|
210
330
|
STRATEGIES = {
|
|
331
|
+
"sliding-window-counter": SlidingWindowCounterRateLimiter,
|
|
211
332
|
"fixed-window": FixedWindowRateLimiter,
|
|
212
333
|
"fixed-window-elastic-expiry": FixedWindowElasticExpiryRateLimiter,
|
|
213
334
|
"moving-window": MovingWindowRateLimiter,
|
limits/limits.py
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from functools import total_ordering
|
|
6
|
-
from typing import Dict, NamedTuple, Optional, Tuple, Type, Union, cast
|
|
7
6
|
|
|
8
|
-
from limits.typing import ClassVar,
|
|
7
|
+
from limits.typing import ClassVar, NamedTuple, cast
|
|
9
8
|
|
|
10
9
|
|
|
11
|
-
def safe_string(value:
|
|
10
|
+
def safe_string(value: bytes | str | int | float) -> str:
|
|
12
11
|
"""
|
|
13
|
-
|
|
12
|
+
normalize a byte/str/int or float to a str
|
|
14
13
|
"""
|
|
15
14
|
|
|
16
15
|
if isinstance(value, bytes):
|
|
@@ -33,15 +32,15 @@ TIME_TYPES = dict(
|
|
|
33
32
|
second=Granularity(1, "second"),
|
|
34
33
|
)
|
|
35
34
|
|
|
36
|
-
GRANULARITIES:
|
|
35
|
+
GRANULARITIES: dict[str, type[RateLimitItem]] = {}
|
|
37
36
|
|
|
38
37
|
|
|
39
38
|
class RateLimitItemMeta(type):
|
|
40
39
|
def __new__(
|
|
41
40
|
cls,
|
|
42
41
|
name: str,
|
|
43
|
-
parents:
|
|
44
|
-
dct:
|
|
42
|
+
parents: tuple[type, ...],
|
|
43
|
+
dct: dict[str, Granularity | list[str]],
|
|
45
44
|
) -> RateLimitItemMeta:
|
|
46
45
|
if "__slots__" not in dct:
|
|
47
46
|
dct["__slots__"] = []
|
|
@@ -49,7 +48,7 @@ class RateLimitItemMeta(type):
|
|
|
49
48
|
|
|
50
49
|
if "GRANULARITY" in dct:
|
|
51
50
|
GRANULARITIES[dct["GRANULARITY"][1]] = cast(
|
|
52
|
-
|
|
51
|
+
type[RateLimitItem], granularity
|
|
53
52
|
)
|
|
54
53
|
|
|
55
54
|
return granularity
|
|
@@ -77,7 +76,7 @@ class RateLimitItem(metaclass=RateLimitItemMeta):
|
|
|
77
76
|
"""
|
|
78
77
|
|
|
79
78
|
def __init__(
|
|
80
|
-
self, amount: int, multiples:
|
|
79
|
+
self, amount: int, multiples: int | None = 1, namespace: str = "LIMITER"
|
|
81
80
|
):
|
|
82
81
|
self.namespace = namespace
|
|
83
82
|
self.amount = int(amount)
|
|
@@ -101,14 +100,14 @@ class RateLimitItem(metaclass=RateLimitItemMeta):
|
|
|
101
100
|
|
|
102
101
|
return self.GRANULARITY.seconds * self.multiples
|
|
103
102
|
|
|
104
|
-
def key_for(self, *identifiers: str) -> str:
|
|
103
|
+
def key_for(self, *identifiers: bytes | str | int | float) -> str:
|
|
105
104
|
"""
|
|
106
105
|
Constructs a key for the current limit and any additional
|
|
107
106
|
identifiers provided.
|
|
108
107
|
|
|
109
108
|
:param identifiers: a list of strings to append to the key
|
|
110
109
|
:return: a string key identifying this resource with
|
|
111
|
-
each identifier
|
|
110
|
+
each identifier separated with a '/' delimiter.
|
|
112
111
|
"""
|
|
113
112
|
remainder = "/".join(
|
|
114
113
|
[safe_string(k) for k in identifiers]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- Time is in milliseconds in this script: TTL, expiry...
|
|
2
|
+
|
|
3
|
+
local limit = tonumber(ARGV[1])
|
|
4
|
+
local expiry = tonumber(ARGV[2]) * 1000
|
|
5
|
+
local amount = tonumber(ARGV[3])
|
|
6
|
+
|
|
7
|
+
if amount > limit then
|
|
8
|
+
return false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
local current_ttl = tonumber(redis.call('pttl', KEYS[2]))
|
|
12
|
+
|
|
13
|
+
if current_ttl > 0 and current_ttl < expiry then
|
|
14
|
+
-- Current window expired, shift it to the previous window
|
|
15
|
+
redis.call('rename', KEYS[2], KEYS[1])
|
|
16
|
+
redis.call('set', KEYS[2], 0, 'PX', current_ttl + expiry)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
local previous_count = tonumber(redis.call('get', KEYS[1])) or 0
|
|
20
|
+
local previous_ttl = tonumber(redis.call('pttl', KEYS[1])) or 0
|
|
21
|
+
local current_count = tonumber(redis.call('get', KEYS[2])) or 0
|
|
22
|
+
current_ttl = tonumber(redis.call('pttl', KEYS[2])) or 0
|
|
23
|
+
|
|
24
|
+
-- If the values don't exist yet, consider the TTL is 0
|
|
25
|
+
if previous_ttl <= 0 then
|
|
26
|
+
previous_ttl = 0
|
|
27
|
+
end
|
|
28
|
+
if current_ttl <= 0 then
|
|
29
|
+
current_ttl = 0
|
|
30
|
+
end
|
|
31
|
+
local weighted_count = math.floor(previous_count * previous_ttl / expiry) + current_count
|
|
32
|
+
|
|
33
|
+
if (weighted_count + amount) > limit then
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
-- If the current counter exists, increase its value
|
|
38
|
+
if redis.call('exists', KEYS[2]) == 1 then
|
|
39
|
+
redis.call('incrby', KEYS[2], amount)
|
|
40
|
+
else
|
|
41
|
+
-- Otherwise, set the value with twice the expiry time
|
|
42
|
+
redis.call('set', KEYS[2], amount, 'PX', expiry * 2)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
local expiry = tonumber(ARGV[1]) * 1000
|
|
2
|
+
local previous_count = redis.call('get', KEYS[1])
|
|
3
|
+
local previous_ttl = redis.call('pttl', KEYS[1])
|
|
4
|
+
local current_count = redis.call('get', KEYS[2])
|
|
5
|
+
local current_ttl = redis.call('pttl', KEYS[2])
|
|
6
|
+
|
|
7
|
+
if current_ttl > 0 and current_ttl < expiry then
|
|
8
|
+
-- Current window expired, shift it to the previous window
|
|
9
|
+
redis.call('rename', KEYS[2], KEYS[1])
|
|
10
|
+
redis.call('set', KEYS[2], 0, 'PX', current_ttl + expiry)
|
|
11
|
+
previous_count = redis.call('get', KEYS[1])
|
|
12
|
+
previous_ttl = redis.call('pttl', KEYS[1])
|
|
13
|
+
current_count = redis.call('get', KEYS[2])
|
|
14
|
+
current_ttl = redis.call('pttl', KEYS[2])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
return {previous_count, previous_ttl, current_count, current_ttl}
|
limits/storage/__init__.py
CHANGED
|
@@ -4,12 +4,12 @@ Implementations of storage backends to be used with
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import urllib
|
|
7
|
-
from typing import Union, cast
|
|
8
7
|
|
|
9
|
-
import limits
|
|
8
|
+
import limits # noqa
|
|
10
9
|
|
|
11
10
|
from ..errors import ConfigurationError
|
|
12
|
-
from
|
|
11
|
+
from ..typing import Union, cast
|
|
12
|
+
from .base import MovingWindowSupport, SlidingWindowCounterSupport, Storage
|
|
13
13
|
from .etcd import EtcdStorage
|
|
14
14
|
from .memcached import MemcachedStorage
|
|
15
15
|
from .memory import MemoryStorage
|
|
@@ -32,9 +32,9 @@ def storage_from_string(
|
|
|
32
32
|
|
|
33
33
|
from limits.storage import storage_from_string
|
|
34
34
|
|
|
35
|
-
memory =
|
|
36
|
-
memcached =
|
|
37
|
-
redis =
|
|
35
|
+
memory = storage_from_string("memory://")
|
|
36
|
+
memcached = storage_from_string("memcached://localhost:11211")
|
|
37
|
+
redis = storage_from_string("redis://localhost:6379")
|
|
38
38
|
|
|
39
39
|
The same function can be used to construct the :ref:`storage:async storage`
|
|
40
40
|
variants, for example::
|
|
@@ -67,6 +67,7 @@ __all__ = [
|
|
|
67
67
|
"storage_from_string",
|
|
68
68
|
"Storage",
|
|
69
69
|
"MovingWindowSupport",
|
|
70
|
+
"SlidingWindowCounterSupport",
|
|
70
71
|
"EtcdStorage",
|
|
71
72
|
"MongoDBStorageBase",
|
|
72
73
|
"MemoryStorage",
|