limits 4.0.1__py3-none-any.whl → 4.2__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.
Files changed (39) hide show
  1. limits/__init__.py +3 -1
  2. limits/_version.py +4 -4
  3. limits/aio/__init__.py +2 -0
  4. limits/aio/storage/__init__.py +4 -1
  5. limits/aio/storage/base.py +70 -24
  6. limits/aio/storage/etcd.py +8 -2
  7. limits/aio/storage/memcached.py +159 -33
  8. limits/aio/storage/memory.py +100 -13
  9. limits/aio/storage/mongodb.py +217 -9
  10. limits/aio/storage/redis/__init__.py +341 -0
  11. limits/aio/storage/redis/bridge.py +121 -0
  12. limits/aio/storage/redis/coredis.py +209 -0
  13. limits/aio/storage/redis/redispy.py +257 -0
  14. limits/aio/strategies.py +124 -1
  15. limits/errors.py +2 -0
  16. limits/limits.py +10 -11
  17. limits/resources/redis/lua_scripts/acquire_sliding_window.lua +45 -0
  18. limits/resources/redis/lua_scripts/sliding_window.lua +17 -0
  19. limits/storage/__init__.py +6 -3
  20. limits/storage/base.py +92 -24
  21. limits/storage/etcd.py +8 -2
  22. limits/storage/memcached.py +143 -34
  23. limits/storage/memory.py +99 -12
  24. limits/storage/mongodb.py +204 -11
  25. limits/storage/redis.py +159 -138
  26. limits/storage/redis_cluster.py +5 -3
  27. limits/storage/redis_sentinel.py +14 -35
  28. limits/storage/registry.py +3 -3
  29. limits/strategies.py +121 -5
  30. limits/typing.py +55 -19
  31. limits/util.py +29 -18
  32. limits-4.2.dist-info/METADATA +268 -0
  33. limits-4.2.dist-info/RECORD +42 -0
  34. limits/aio/storage/redis.py +0 -470
  35. limits-4.0.1.dist-info/METADATA +0 -192
  36. limits-4.0.1.dist-info/RECORD +0 -37
  37. {limits-4.0.1.dist-info → limits-4.2.dist-info}/LICENSE.txt +0 -0
  38. {limits-4.0.1.dist-info → limits-4.2.dist-info}/WHEEL +0 -0
  39. {limits-4.0.1.dist-info → limits-4.2.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,20 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import time
3
- from collections import Counter
5
+ from collections import Counter, defaultdict
6
+ from math import floor
4
7
 
5
8
  from deprecated.sphinx import versionadded
6
9
 
7
10
  import limits.typing
8
- from limits.aio.storage.base import MovingWindowSupport, Storage
9
- from limits.typing import Dict, List, Optional, Tuple, Type, Union
11
+ from limits.aio.storage.base import (
12
+ MovingWindowSupport,
13
+ SlidingWindowCounterSupport,
14
+ Storage,
15
+ )
16
+ from limits.storage.base import TimestampedSlidingWindow
17
+ from limits.typing import Optional, Type, Union
10
18
 
11
19
 
12
20
  class LockableEntry(asyncio.Lock):
@@ -17,7 +25,9 @@ class LockableEntry(asyncio.Lock):
17
25
 
18
26
 
19
27
  @versionadded(version="2.1")
20
- class MemoryStorage(Storage, MovingWindowSupport):
28
+ class MemoryStorage(
29
+ Storage, MovingWindowSupport, SlidingWindowCounterSupport, TimestampedSlidingWindow
30
+ ):
21
31
  """
22
32
  rate limit storage using :class:`collections.Counter`
23
33
  as an in memory storage for fixed and elastic window strategies,
@@ -34,15 +44,16 @@ class MemoryStorage(Storage, MovingWindowSupport):
34
44
  self, uri: Optional[str] = None, wrap_exceptions: bool = False, **_: str
35
45
  ) -> None:
36
46
  self.storage: limits.typing.Counter[str] = Counter()
37
- self.expirations: Dict[str, float] = {}
38
- self.events: Dict[str, List[LockableEntry]] = {}
47
+ self.locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
48
+ self.expirations: dict[str, float] = {}
49
+ self.events: dict[str, list[LockableEntry]] = {}
39
50
  self.timer: Optional[asyncio.Task[None]] = None
40
51
  super().__init__(uri, wrap_exceptions=wrap_exceptions, **_)
41
52
 
42
53
  @property
43
54
  def base_exceptions(
44
55
  self,
45
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
56
+ ) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
46
57
  return ValueError
47
58
 
48
59
  async def __expire_events(self) -> None:
@@ -56,13 +67,14 @@ class MemoryStorage(Storage, MovingWindowSupport):
56
67
  if self.expirations[key] <= time.time():
57
68
  self.storage.pop(key, None)
58
69
  self.expirations.pop(key, None)
70
+ self.locks.pop(key, None)
59
71
 
60
72
  async def __schedule_expiry(self) -> None:
61
73
  if not self.timer or self.timer.done():
62
74
  self.timer = asyncio.create_task(self.__expire_events())
63
75
 
64
76
  async def incr(
65
- self, key: str, expiry: int, elastic_expiry: bool = False, amount: int = 1
77
+ self, key: str, expiry: float, elastic_expiry: bool = False, amount: int = 1
66
78
  ) -> int:
67
79
  """
68
80
  increments the counter for a given rate limit key
@@ -75,10 +87,24 @@ class MemoryStorage(Storage, MovingWindowSupport):
75
87
  """
76
88
  await self.get(key)
77
89
  await self.__schedule_expiry()
78
- self.storage[key] += amount
90
+ async with self.locks[key]:
91
+ self.storage[key] += amount
92
+
93
+ if elastic_expiry or self.storage[key] == amount:
94
+ self.expirations[key] = time.time() + expiry
95
+
96
+ return self.storage.get(key, amount)
97
+
98
+ async def decr(self, key: str, amount: int = 1) -> int:
99
+ """
100
+ decrements the counter for a given rate limit key. 0 is the minimum allowed value.
79
101
 
80
- if elastic_expiry or self.storage[key] == amount:
81
- self.expirations[key] = time.time() + expiry
102
+ :param amount: the number to increment by
103
+ """
104
+ await self.get(key)
105
+ await self.__schedule_expiry()
106
+ async with self.locks[key]:
107
+ self.storage[key] = max(self.storage[key] - amount, 0)
82
108
 
83
109
  return self.storage.get(key, amount)
84
110
 
@@ -86,10 +112,10 @@ class MemoryStorage(Storage, MovingWindowSupport):
86
112
  """
87
113
  :param key: the key to get the counter value for
88
114
  """
89
-
90
115
  if self.expirations.get(key, 0) <= time.time():
91
116
  self.storage.pop(key, None)
92
117
  self.expirations.pop(key, None)
118
+ self.locks.pop(key, None)
93
119
 
94
120
  return self.storage.get(key, 0)
95
121
 
@@ -100,6 +126,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
100
126
  self.storage.pop(key, None)
101
127
  self.expirations.pop(key, None)
102
128
  self.events.pop(key, None)
129
+ self.locks.pop(key, None)
103
130
 
104
131
  async def acquire_entry(
105
132
  self, key: str, limit: int, expiry: int, amount: int = 1
@@ -153,7 +180,7 @@ class MemoryStorage(Storage, MovingWindowSupport):
153
180
  # FIXME: arg limit is not used
154
181
  async def get_moving_window(
155
182
  self, key: str, limit: int, expiry: int
156
- ) -> Tuple[float, int]:
183
+ ) -> tuple[float, int]:
157
184
  """
158
185
  returns the starting point and the number of entries in the moving
159
186
  window
@@ -171,6 +198,65 @@ class MemoryStorage(Storage, MovingWindowSupport):
171
198
 
172
199
  return timestamp, acquired
173
200
 
201
+ async def acquire_sliding_window_entry(
202
+ self,
203
+ key: str,
204
+ limit: int,
205
+ expiry: int,
206
+ amount: int = 1,
207
+ ) -> bool:
208
+ if amount > limit:
209
+ return False
210
+ now = time.time()
211
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
212
+ (
213
+ previous_count,
214
+ previous_ttl,
215
+ current_count,
216
+ _,
217
+ ) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
218
+ weighted_count = previous_count * previous_ttl / expiry + current_count
219
+ if floor(weighted_count) + amount > limit:
220
+ return False
221
+ else:
222
+ # Hit, increase the current counter.
223
+ # If the counter doesn't exist yet, set twice the theorical expiry.
224
+ current_count = await self.incr(current_key, 2 * expiry, amount=amount)
225
+ weighted_count = previous_count * previous_ttl / expiry + current_count
226
+ if floor(weighted_count) > limit:
227
+ # Another hit won the race condition: revert the incrementation and refuse this hit
228
+ # Limitation: during high concurrency at the end of the window,
229
+ # the counter is shifted and cannot be decremented, so less requests than expected are allowed.
230
+ await self.decr(current_key, amount)
231
+ # print("Concurrent call, reverting the counter increase")
232
+ return False
233
+ return True
234
+
235
+ async def get_sliding_window(
236
+ self, key: str, expiry: int
237
+ ) -> tuple[int, float, int, float]:
238
+ now = time.time()
239
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
240
+ return await self._get_sliding_window_info(
241
+ previous_key, current_key, expiry, now
242
+ )
243
+
244
+ async def _get_sliding_window_info(
245
+ self,
246
+ previous_key: str,
247
+ current_key: str,
248
+ expiry: int,
249
+ now: float,
250
+ ) -> tuple[int, float, int, float]:
251
+ previous_count = await self.get(previous_key)
252
+ current_count = await self.get(current_key)
253
+ if previous_count == 0:
254
+ previous_ttl = float(0)
255
+ else:
256
+ previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
257
+ current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
258
+ return previous_count, previous_ttl, current_count, current_ttl
259
+
174
260
  async def check(self) -> bool:
175
261
  """
176
262
  check if storage is healthy
@@ -183,5 +269,6 @@ class MemoryStorage(Storage, MovingWindowSupport):
183
269
  self.storage.clear()
184
270
  self.expirations.clear()
185
271
  self.events.clear()
272
+ self.locks.clear()
186
273
 
187
274
  return num_items
@@ -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 MovingWindowSupport, Storage
11
- from limits.typing import Dict, List, Optional, ParamSpec, Tuple, Type, TypeVar, Union
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 storage
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], Tuple[Type[Exception], ...]]: # pragma: no cover
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
- ) -> Tuple[float, int]:
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: Dict[
295
+ updates: dict[
285
296
  str,
286
- Dict[str, Union[datetime.datetime, Dict[str, Union[List[float], int]]]],
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