limits 4.0.1__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 +217 -9
- 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 +4 -3
- limits/storage/base.py +92 -24
- limits/storage/etcd.py +6 -2
- limits/storage/memcached.py +141 -34
- limits/storage/memory.py +97 -12
- limits/storage/mongodb.py +204 -11
- 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.1.dist-info/METADATA +0 -192
- limits-4.0.1.dist-info/RECORD +0 -37
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/LICENSE.txt +0 -0
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/WHEEL +0 -0
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/top_level.txt +0 -0
limits/aio/storage/memory.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
|
-
from collections import Counter
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from math import floor
|
|
4
5
|
|
|
5
6
|
from deprecated.sphinx import versionadded
|
|
6
7
|
|
|
7
8
|
import limits.typing
|
|
8
|
-
from limits.aio.storage.base import
|
|
9
|
-
|
|
9
|
+
from limits.aio.storage.base import (
|
|
10
|
+
MovingWindowSupport,
|
|
11
|
+
SlidingWindowCounterSupport,
|
|
12
|
+
Storage,
|
|
13
|
+
)
|
|
14
|
+
from limits.storage.base import TimestampedSlidingWindow
|
|
15
|
+
from limits.typing import Optional, Type, Union
|
|
10
16
|
|
|
11
17
|
|
|
12
18
|
class LockableEntry(asyncio.Lock):
|
|
@@ -17,7 +23,9 @@ class LockableEntry(asyncio.Lock):
|
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
@versionadded(version="2.1")
|
|
20
|
-
class MemoryStorage(
|
|
26
|
+
class MemoryStorage(
|
|
27
|
+
Storage, MovingWindowSupport, SlidingWindowCounterSupport, TimestampedSlidingWindow
|
|
28
|
+
):
|
|
21
29
|
"""
|
|
22
30
|
rate limit storage using :class:`collections.Counter`
|
|
23
31
|
as an in memory storage for fixed and elastic window strategies,
|
|
@@ -34,15 +42,16 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
34
42
|
self, uri: Optional[str] = None, wrap_exceptions: bool = False, **_: str
|
|
35
43
|
) -> None:
|
|
36
44
|
self.storage: limits.typing.Counter[str] = Counter()
|
|
37
|
-
self.
|
|
38
|
-
self.
|
|
45
|
+
self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
|
46
|
+
self.expirations: dict[str, float] = {}
|
|
47
|
+
self.events: dict[str, list[LockableEntry]] = {}
|
|
39
48
|
self.timer: Optional[asyncio.Task[None]] = None
|
|
40
49
|
super().__init__(uri, wrap_exceptions=wrap_exceptions, **_)
|
|
41
50
|
|
|
42
51
|
@property
|
|
43
52
|
def base_exceptions(
|
|
44
53
|
self,
|
|
45
|
-
) -> Union[Type[Exception],
|
|
54
|
+
) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
|
|
46
55
|
return ValueError
|
|
47
56
|
|
|
48
57
|
async def __expire_events(self) -> None:
|
|
@@ -56,13 +65,14 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
56
65
|
if self.expirations[key] <= time.time():
|
|
57
66
|
self.storage.pop(key, None)
|
|
58
67
|
self.expirations.pop(key, None)
|
|
68
|
+
self.locks.pop(key, None)
|
|
59
69
|
|
|
60
70
|
async def __schedule_expiry(self) -> None:
|
|
61
71
|
if not self.timer or self.timer.done():
|
|
62
72
|
self.timer = asyncio.create_task(self.__expire_events())
|
|
63
73
|
|
|
64
74
|
async def incr(
|
|
65
|
-
self, key: str, expiry:
|
|
75
|
+
self, key: str, expiry: float, elastic_expiry: bool = False, amount: int = 1
|
|
66
76
|
) -> int:
|
|
67
77
|
"""
|
|
68
78
|
increments the counter for a given rate limit key
|
|
@@ -75,10 +85,24 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
75
85
|
"""
|
|
76
86
|
await self.get(key)
|
|
77
87
|
await self.__schedule_expiry()
|
|
78
|
-
self.
|
|
88
|
+
async with self.locks[key]:
|
|
89
|
+
self.storage[key] += amount
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
if elastic_expiry or self.storage[key] == amount:
|
|
92
|
+
self.expirations[key] = time.time() + expiry
|
|
93
|
+
|
|
94
|
+
return self.storage.get(key, amount)
|
|
95
|
+
|
|
96
|
+
async def decr(self, key: str, amount: int = 1) -> int:
|
|
97
|
+
"""
|
|
98
|
+
decrements the counter for a given rate limit key. 0 is the minimum allowed value.
|
|
99
|
+
|
|
100
|
+
:param amount: the number to increment by
|
|
101
|
+
"""
|
|
102
|
+
await self.get(key)
|
|
103
|
+
await self.__schedule_expiry()
|
|
104
|
+
async with self.locks[key]:
|
|
105
|
+
self.storage[key] = max(self.storage[key] - amount, 0)
|
|
82
106
|
|
|
83
107
|
return self.storage.get(key, amount)
|
|
84
108
|
|
|
@@ -86,10 +110,10 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
86
110
|
"""
|
|
87
111
|
:param key: the key to get the counter value for
|
|
88
112
|
"""
|
|
89
|
-
|
|
90
113
|
if self.expirations.get(key, 0) <= time.time():
|
|
91
114
|
self.storage.pop(key, None)
|
|
92
115
|
self.expirations.pop(key, None)
|
|
116
|
+
self.locks.pop(key, None)
|
|
93
117
|
|
|
94
118
|
return self.storage.get(key, 0)
|
|
95
119
|
|
|
@@ -100,6 +124,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
100
124
|
self.storage.pop(key, None)
|
|
101
125
|
self.expirations.pop(key, None)
|
|
102
126
|
self.events.pop(key, None)
|
|
127
|
+
self.locks.pop(key, None)
|
|
103
128
|
|
|
104
129
|
async def acquire_entry(
|
|
105
130
|
self, key: str, limit: int, expiry: int, amount: int = 1
|
|
@@ -153,7 +178,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
153
178
|
# FIXME: arg limit is not used
|
|
154
179
|
async def get_moving_window(
|
|
155
180
|
self, key: str, limit: int, expiry: int
|
|
156
|
-
) ->
|
|
181
|
+
) -> tuple[float, int]:
|
|
157
182
|
"""
|
|
158
183
|
returns the starting point and the number of entries in the moving
|
|
159
184
|
window
|
|
@@ -171,6 +196,65 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
171
196
|
|
|
172
197
|
return timestamp, acquired
|
|
173
198
|
|
|
199
|
+
async def acquire_sliding_window_entry(
|
|
200
|
+
self,
|
|
201
|
+
key: str,
|
|
202
|
+
limit: int,
|
|
203
|
+
expiry: int,
|
|
204
|
+
amount: int = 1,
|
|
205
|
+
) -> bool:
|
|
206
|
+
if amount > limit:
|
|
207
|
+
return False
|
|
208
|
+
now = time.time()
|
|
209
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
210
|
+
(
|
|
211
|
+
previous_count,
|
|
212
|
+
previous_ttl,
|
|
213
|
+
current_count,
|
|
214
|
+
_,
|
|
215
|
+
) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
|
|
216
|
+
weighted_count = previous_count * previous_ttl / expiry + current_count
|
|
217
|
+
if floor(weighted_count) + amount > limit:
|
|
218
|
+
return False
|
|
219
|
+
else:
|
|
220
|
+
# Hit, increase the current counter.
|
|
221
|
+
# If the counter doesn't exist yet, set twice the theorical expiry.
|
|
222
|
+
current_count = await self.incr(current_key, 2 * expiry, amount=amount)
|
|
223
|
+
weighted_count = previous_count * previous_ttl / expiry + current_count
|
|
224
|
+
if floor(weighted_count) > limit:
|
|
225
|
+
# Another hit won the race condition: revert the incrementation and refuse this hit
|
|
226
|
+
# Limitation: during high concurrency at the end of the window,
|
|
227
|
+
# the counter is shifted and cannot be decremented, so less requests than expected are allowed.
|
|
228
|
+
await self.decr(current_key, amount)
|
|
229
|
+
# print("Concurrent call, reverting the counter increase")
|
|
230
|
+
return False
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
async def get_sliding_window(
|
|
234
|
+
self, key: str, expiry: int
|
|
235
|
+
) -> tuple[int, float, int, float]:
|
|
236
|
+
now = time.time()
|
|
237
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
238
|
+
return await self._get_sliding_window_info(
|
|
239
|
+
previous_key, current_key, expiry, now
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def _get_sliding_window_info(
|
|
243
|
+
self,
|
|
244
|
+
previous_key: str,
|
|
245
|
+
current_key: str,
|
|
246
|
+
expiry: int,
|
|
247
|
+
now: float,
|
|
248
|
+
) -> tuple[int, float, int, float]:
|
|
249
|
+
previous_count = await self.get(previous_key)
|
|
250
|
+
current_count = await self.get(current_key)
|
|
251
|
+
if previous_count == 0:
|
|
252
|
+
previous_ttl = float(0)
|
|
253
|
+
else:
|
|
254
|
+
previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
|
|
255
|
+
current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
|
|
256
|
+
return previous_count, previous_ttl, current_count, current_ttl
|
|
257
|
+
|
|
174
258
|
async def check(self) -> bool:
|
|
175
259
|
"""
|
|
176
260
|
check if storage is healthy
|
|
@@ -183,5 +267,6 @@ class MemoryStorage(Storage, MovingWindowSupport):
|
|
|
183
267
|
self.storage.clear()
|
|
184
268
|
self.expirations.clear()
|
|
185
269
|
self.events.clear()
|
|
270
|
+
self.locks.clear()
|
|
186
271
|
|
|
187
272
|
return num_items
|
limits/aio/storage/mongodb.py
CHANGED
|
@@ -3,12 +3,22 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import datetime
|
|
5
5
|
import time
|
|
6
|
-
from typing import cast
|
|
7
6
|
|
|
8
7
|
from deprecated.sphinx import versionadded, versionchanged
|
|
9
8
|
|
|
10
|
-
from limits.aio.storage.base import
|
|
11
|
-
|
|
9
|
+
from limits.aio.storage.base import (
|
|
10
|
+
MovingWindowSupport,
|
|
11
|
+
SlidingWindowCounterSupport,
|
|
12
|
+
Storage,
|
|
13
|
+
)
|
|
14
|
+
from limits.typing import (
|
|
15
|
+
Optional,
|
|
16
|
+
ParamSpec,
|
|
17
|
+
Type,
|
|
18
|
+
TypeVar,
|
|
19
|
+
Union,
|
|
20
|
+
cast,
|
|
21
|
+
)
|
|
12
22
|
from limits.util import get_dependency
|
|
13
23
|
|
|
14
24
|
P = ParamSpec("P")
|
|
@@ -20,7 +30,7 @@ R = TypeVar("R")
|
|
|
20
30
|
version="3.14.0",
|
|
21
31
|
reason="Added option to select custom collection names for windows & counters",
|
|
22
32
|
)
|
|
23
|
-
class MongoDBStorage(Storage, MovingWindowSupport):
|
|
33
|
+
class MongoDBStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
24
34
|
"""
|
|
25
35
|
Rate limit storage with MongoDB as backend.
|
|
26
36
|
|
|
@@ -50,7 +60,8 @@ class MongoDBStorage(Storage, MovingWindowSupport):
|
|
|
50
60
|
collections.
|
|
51
61
|
:param counter_collection_name: The collection name to use for individual counters
|
|
52
62
|
used in fixed window strategies
|
|
53
|
-
:param window_collection_name: The collection name to use for moving window
|
|
63
|
+
:param window_collection_name: The collection name to use for sliding & moving window
|
|
64
|
+
storage
|
|
54
65
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
|
55
66
|
:exc:`limits.errors.StorageError` before raising it.
|
|
56
67
|
:param options: all remaining keyword arguments are passed
|
|
@@ -83,7 +94,7 @@ class MongoDBStorage(Storage, MovingWindowSupport):
|
|
|
83
94
|
@property
|
|
84
95
|
def base_exceptions(
|
|
85
96
|
self,
|
|
86
|
-
) -> Union[Type[Exception],
|
|
97
|
+
) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
|
|
87
98
|
return self.lib_errors.PyMongoError # type: ignore
|
|
88
99
|
|
|
89
100
|
@property
|
|
@@ -224,7 +235,7 @@ class MongoDBStorage(Storage, MovingWindowSupport):
|
|
|
224
235
|
|
|
225
236
|
async def get_moving_window(
|
|
226
237
|
self, key: str, limit: int, expiry: int
|
|
227
|
-
) ->
|
|
238
|
+
) -> tuple[float, int]:
|
|
228
239
|
"""
|
|
229
240
|
returns the starting point and the number of entries in the moving
|
|
230
241
|
window
|
|
@@ -281,9 +292,9 @@ class MongoDBStorage(Storage, MovingWindowSupport):
|
|
|
281
292
|
|
|
282
293
|
timestamp = time.time()
|
|
283
294
|
try:
|
|
284
|
-
updates:
|
|
295
|
+
updates: dict[
|
|
285
296
|
str,
|
|
286
|
-
|
|
297
|
+
dict[str, Union[datetime.datetime, dict[str, Union[list[float], int]]]],
|
|
287
298
|
] = {
|
|
288
299
|
"$push": {
|
|
289
300
|
"entries": {
|
|
@@ -312,3 +323,200 @@ class MongoDBStorage(Storage, MovingWindowSupport):
|
|
|
312
323
|
return True
|
|
313
324
|
except self.proxy_dependency.module.errors.DuplicateKeyError:
|
|
314
325
|
return False
|
|
326
|
+
|
|
327
|
+
async def acquire_sliding_window_entry(
|
|
328
|
+
self, key: str, limit: int, expiry: int, amount: int = 1
|
|
329
|
+
) -> bool:
|
|
330
|
+
await self.create_indices()
|
|
331
|
+
expiry_ms = expiry * 1000
|
|
332
|
+
result = await self.database[
|
|
333
|
+
self.__collection_mapping["windows"]
|
|
334
|
+
].find_one_and_update(
|
|
335
|
+
{"_id": key},
|
|
336
|
+
[
|
|
337
|
+
{
|
|
338
|
+
"$set": {
|
|
339
|
+
"previousCount": {
|
|
340
|
+
"$cond": {
|
|
341
|
+
"if": {
|
|
342
|
+
"$lte": [
|
|
343
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
344
|
+
expiry_ms,
|
|
345
|
+
]
|
|
346
|
+
},
|
|
347
|
+
"then": {"$ifNull": ["$currentCount", 0]},
|
|
348
|
+
"else": {"$ifNull": ["$previousCount", 0]},
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
"$set": {
|
|
355
|
+
"currentCount": {
|
|
356
|
+
"$cond": {
|
|
357
|
+
"if": {
|
|
358
|
+
"$lte": [
|
|
359
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
360
|
+
expiry_ms,
|
|
361
|
+
]
|
|
362
|
+
},
|
|
363
|
+
"then": 0,
|
|
364
|
+
"else": {"$ifNull": ["$currentCount", 0]},
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
"expiresAt": {
|
|
368
|
+
"$cond": {
|
|
369
|
+
"if": {
|
|
370
|
+
"$lte": [
|
|
371
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
372
|
+
expiry_ms,
|
|
373
|
+
]
|
|
374
|
+
},
|
|
375
|
+
"then": {
|
|
376
|
+
"$cond": {
|
|
377
|
+
"if": {"$gt": ["$expiresAt", 0]},
|
|
378
|
+
"then": {"$add": ["$expiresAt", expiry_ms]},
|
|
379
|
+
"else": {"$add": ["$$NOW", 2 * expiry_ms]},
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
"else": "$expiresAt",
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"$set": {
|
|
389
|
+
"curWeightedCount": {
|
|
390
|
+
"$floor": {
|
|
391
|
+
"$add": [
|
|
392
|
+
{
|
|
393
|
+
"$multiply": [
|
|
394
|
+
"$previousCount",
|
|
395
|
+
{
|
|
396
|
+
"$divide": [
|
|
397
|
+
{
|
|
398
|
+
"$max": [
|
|
399
|
+
0,
|
|
400
|
+
{
|
|
401
|
+
"$subtract": [
|
|
402
|
+
"$expiresAt",
|
|
403
|
+
{
|
|
404
|
+
"$add": [
|
|
405
|
+
"$$NOW",
|
|
406
|
+
expiry_ms,
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
]
|
|
410
|
+
},
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
expiry_ms,
|
|
414
|
+
]
|
|
415
|
+
},
|
|
416
|
+
]
|
|
417
|
+
},
|
|
418
|
+
"$currentCount",
|
|
419
|
+
]
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"$set": {
|
|
426
|
+
"currentCount": {
|
|
427
|
+
"$cond": {
|
|
428
|
+
"if": {
|
|
429
|
+
"$lte": [
|
|
430
|
+
{"$add": ["$curWeightedCount", amount]},
|
|
431
|
+
limit,
|
|
432
|
+
]
|
|
433
|
+
},
|
|
434
|
+
"then": {"$add": ["$currentCount", amount]},
|
|
435
|
+
"else": "$currentCount",
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
"$set": {
|
|
442
|
+
"_acquired": {
|
|
443
|
+
"$lte": [{"$add": ["$curWeightedCount", amount]}, limit]
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
{"$unset": ["curWeightedCount"]},
|
|
448
|
+
],
|
|
449
|
+
return_document=self.proxy_dependency.module.ReturnDocument.AFTER,
|
|
450
|
+
upsert=True,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return cast(bool, result["_acquired"])
|
|
454
|
+
|
|
455
|
+
async def get_sliding_window(
|
|
456
|
+
self, key: str, expiry: int
|
|
457
|
+
) -> tuple[int, float, int, float]:
|
|
458
|
+
expiry_ms = expiry * 1000
|
|
459
|
+
if result := await self.database[
|
|
460
|
+
self.__collection_mapping["windows"]
|
|
461
|
+
].find_one_and_update(
|
|
462
|
+
{"_id": key},
|
|
463
|
+
[
|
|
464
|
+
{
|
|
465
|
+
"$set": {
|
|
466
|
+
"previousCount": {
|
|
467
|
+
"$cond": {
|
|
468
|
+
"if": {
|
|
469
|
+
"$lte": [
|
|
470
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
471
|
+
expiry_ms,
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
"then": {"$ifNull": ["$currentCount", 0]},
|
|
475
|
+
"else": {"$ifNull": ["$previousCount", 0]},
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
"currentCount": {
|
|
479
|
+
"$cond": {
|
|
480
|
+
"if": {
|
|
481
|
+
"$lte": [
|
|
482
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
483
|
+
expiry_ms,
|
|
484
|
+
]
|
|
485
|
+
},
|
|
486
|
+
"then": 0,
|
|
487
|
+
"else": {"$ifNull": ["$currentCount", 0]},
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
"expiresAt": {
|
|
491
|
+
"$cond": {
|
|
492
|
+
"if": {
|
|
493
|
+
"$lte": [
|
|
494
|
+
{"$subtract": ["$expiresAt", "$$NOW"]},
|
|
495
|
+
expiry_ms,
|
|
496
|
+
]
|
|
497
|
+
},
|
|
498
|
+
"then": {"$add": ["$expiresAt", expiry_ms]},
|
|
499
|
+
"else": "$expiresAt",
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
],
|
|
505
|
+
return_document=self.proxy_dependency.module.ReturnDocument.AFTER,
|
|
506
|
+
projection=["currentCount", "previousCount", "expiresAt"],
|
|
507
|
+
):
|
|
508
|
+
expires_at = (
|
|
509
|
+
(result["expiresAt"].replace(tzinfo=datetime.timezone.utc).timestamp())
|
|
510
|
+
if result.get("expiresAt")
|
|
511
|
+
else time.time()
|
|
512
|
+
)
|
|
513
|
+
current_ttl = max(0, expires_at - time.time())
|
|
514
|
+
prev_ttl = max(0, current_ttl - expiry if result["previousCount"] else 0)
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
result["previousCount"],
|
|
518
|
+
prev_ttl,
|
|
519
|
+
result["currentCount"],
|
|
520
|
+
current_ttl,
|
|
521
|
+
)
|
|
522
|
+
return 0, 0.0, 0, 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
|