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.
@@ -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 MovingWindowSupport, Storage
9
- from limits.typing import Dict, List, Optional, Tuple, Type, Union
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(Storage, MovingWindowSupport):
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.expirations: Dict[str, float] = {}
38
- self.events: Dict[str, List[LockableEntry]] = {}
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], Tuple[Type[Exception], ...]]: # pragma: no cover
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: int, elastic_expiry: bool = False, amount: int = 1
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.storage[key] += amount
88
+ async with self.locks[key]:
89
+ self.storage[key] += amount
79
90
 
80
- if elastic_expiry or self.storage[key] == amount:
81
- self.expirations[key] = time.time() + expiry
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
- ) -> Tuple[float, int]:
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
@@ -3,12 +3,22 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import datetime
5
5
  import time
6
- from typing import Any, 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, 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,23 +292,29 @@ class MongoDBStorage(Storage, MovingWindowSupport):
281
292
 
282
293
  timestamp = time.time()
283
294
  try:
284
- updates: Dict[str, Any] = { # type: ignore
285
- "$push": {"entries": {"$each": [], "$position": 0, "$slice": limit}}
295
+ updates: dict[
296
+ str,
297
+ dict[str, Union[datetime.datetime, dict[str, Union[list[float], int]]]],
298
+ ] = {
299
+ "$push": {
300
+ "entries": {
301
+ "$each": [timestamp] * amount,
302
+ "$position": 0,
303
+ "$slice": limit,
304
+ }
305
+ },
306
+ "$set": {
307
+ "expireAt": (
308
+ datetime.datetime.now(datetime.timezone.utc)
309
+ + datetime.timedelta(seconds=expiry)
310
+ )
311
+ },
286
312
  }
287
313
 
288
- updates["$set"] = {
289
- "expireAt": (
290
- datetime.datetime.now(datetime.timezone.utc)
291
- + datetime.timedelta(seconds=expiry)
292
- )
293
- }
294
- updates["$push"]["entries"]["$each"] = [timestamp] * amount
295
314
  await self.database[self.__collection_mapping["windows"]].update_one(
296
315
  {
297
316
  "_id": key,
298
- "entries.%d" % (limit - amount): {
299
- "$not": {"$gte": timestamp - expiry}
300
- },
317
+ f"entries.{limit - amount}": {"$not": {"$gte": timestamp - expiry}},
301
318
  },
302
319
  updates,
303
320
  upsert=True,
@@ -306,3 +323,200 @@ class MongoDBStorage(Storage, MovingWindowSupport):
306
323
  return True
307
324
  except self.proxy_dependency.module.errors.DuplicateKeyError:
308
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