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.
@@ -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 MovingWindowSupport, Storage
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, Dict, Optional, Tuple, Type, Union
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
- lua_acquire_window: "coredis.commands.Script[bytes]"
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
- ) -> Tuple[float, int]:
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: (start of window, number of acquired entries)
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.lua_acquire_window.execute(
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(RedisInteractor, Storage, MovingWindowSupport):
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], Tuple[Type[Exception], ...]]: # pragma: no cover
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.lua_acquire_window = self.storage.register_script(
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
- RedisStorage.SCRIPT_INCR_EXPIRE
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: Dict[str, Union[float, str, bool]] = {
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: Dict[str, Union[float, str, bool]] = {}
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[Dict[str, Union[float, str, bool]]] = None,
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: Dict[str, Union[float, str, bool]] = {}
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 typing import cast
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, List
7
+ from limits.typing import ClassVar, NamedTuple, cast
9
8
 
10
9
 
11
- def safe_string(value: Union[bytes, str, int]) -> str:
10
+ def safe_string(value: bytes | str | int | float) -> str:
12
11
  """
13
- converts a byte/str or int to a str
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: Dict[str, Type[RateLimitItem]] = {}
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: Tuple[type, ...],
44
- dct: Dict[str, Union[Granularity, List[str]]],
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
- Type[RateLimitItem], granularity
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: Optional[int] = 1, namespace: str = "LIMITER"
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 appended with a '/' delimiter.
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}
@@ -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 .base import MovingWindowSupport, Storage
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 = from_string("memory://")
36
- memcached = from_string("memcached://localhost:11211")
37
- redis = from_string("redis://localhost:6379")
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",