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/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
@@ -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",
limits/storage/base.py CHANGED
@@ -3,30 +3,32 @@ from __future__ import annotations
3
3
  import functools
4
4
  import threading
5
5
  from abc import ABC, abstractmethod
6
- from typing import Any, cast
7
6
 
8
7
  from limits import errors
9
8
  from limits.storage.registry import StorageRegistry
10
9
  from limits.typing import (
10
+ Any,
11
11
  Callable,
12
- List,
13
12
  Optional,
14
13
  P,
15
14
  R,
16
- Tuple,
17
15
  Type,
18
16
  Union,
17
+ cast,
19
18
  )
20
19
  from limits.util import LazyDependency
21
20
 
22
21
 
23
- def _wrap_errors(storage: Storage, fn: Callable[P, R]) -> Callable[P, R]:
22
+ def _wrap_errors(
23
+ fn: Callable[P, R],
24
+ ) -> Callable[P, R]:
24
25
  @functools.wraps(fn)
25
26
  def inner(*args: P.args, **kwargs: P.kwargs) -> R:
27
+ instance = cast(Storage, args[0])
26
28
  try:
27
29
  return fn(*args, **kwargs)
28
- except storage.base_exceptions as exc:
29
- if storage.wrap_exceptions:
30
+ except instance.base_exceptions as exc:
31
+ if instance.wrap_exceptions:
30
32
  raise errors.StorageError(exc) from exc
31
33
  raise
32
34
 
@@ -38,12 +40,10 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
38
40
  Base class to extend when implementing a storage backend.
39
41
  """
40
42
 
41
- STORAGE_SCHEME: Optional[List[str]]
43
+ STORAGE_SCHEME: Optional[list[str]]
42
44
  """The storage schemes to register against this implementation"""
43
45
 
44
- def __new__(cls, *args: Any, **kwargs: Any) -> Storage: # type: ignore[misc]
45
- inst = super().__new__(cls)
46
-
46
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
47
47
  for method in {
48
48
  "incr",
49
49
  "get",
@@ -52,9 +52,8 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
52
52
  "reset",
53
53
  "clear",
54
54
  }:
55
- setattr(inst, method, _wrap_errors(inst, getattr(inst, method)))
56
-
57
- return inst
55
+ setattr(cls, method, _wrap_errors(getattr(cls, method)))
56
+ super().__init_subclass__(**kwargs)
58
57
 
59
58
  def __init__(
60
59
  self,
@@ -73,7 +72,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
73
72
 
74
73
  @property
75
74
  @abstractmethod
76
- def base_exceptions(self) -> Union[Type[Exception], Tuple[Type[Exception], ...]]:
75
+ def base_exceptions(self) -> Union[Type[Exception], tuple[Type[Exception], ...]]:
77
76
  raise NotImplementedError
78
77
 
79
78
  @abstractmethod
@@ -131,24 +130,21 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
131
130
 
132
131
  class MovingWindowSupport(ABC):
133
132
  """
134
- Abstract base for storages that intend to support
135
- the moving window strategy
133
+ Abstract base class for storages that support
134
+ the :ref:`strategies:moving window` strategy
136
135
  """
137
136
 
138
- def __new__(cls, *args: Any, **kwargs: Any) -> MovingWindowSupport: # type: ignore[misc]
139
- inst = super().__new__(cls)
140
-
137
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
141
138
  for method in {
142
139
  "acquire_entry",
143
140
  "get_moving_window",
144
141
  }:
145
142
  setattr(
146
- inst,
143
+ cls,
147
144
  method,
148
- _wrap_errors(cast(Storage, inst), getattr(inst, method)),
145
+ _wrap_errors(getattr(cls, method)),
149
146
  )
150
-
151
- return inst
147
+ super().__init_subclass__(**kwargs)
152
148
 
153
149
  @abstractmethod
154
150
  def acquire_entry(self, key: str, limit: int, expiry: int, amount: int = 1) -> bool:
@@ -161,7 +157,7 @@ class MovingWindowSupport(ABC):
161
157
  raise NotImplementedError
162
158
 
163
159
  @abstractmethod
164
- def get_moving_window(self, key: str, limit: int, expiry: int) -> Tuple[float, int]:
160
+ def get_moving_window(self, key: str, limit: int, expiry: int) -> tuple[float, int]:
165
161
  """
166
162
  returns the starting point and the number of entries in the moving
167
163
  window
@@ -171,3 +167,75 @@ class MovingWindowSupport(ABC):
171
167
  :return: (start of window, number of acquired entries)
172
168
  """
173
169
  raise NotImplementedError
170
+
171
+
172
+ class SlidingWindowCounterSupport(ABC):
173
+ """
174
+ Abstract base class for storages that support
175
+ the :ref:`strategies:sliding window counter` strategy.
176
+ """
177
+
178
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
179
+ for method in {"acquire_sliding_window_entry", "get_sliding_window"}:
180
+ setattr(
181
+ cls,
182
+ method,
183
+ _wrap_errors(getattr(cls, method)),
184
+ )
185
+ super().__init_subclass__(**kwargs)
186
+
187
+ @abstractmethod
188
+ def acquire_sliding_window_entry(
189
+ self, key: str, limit: int, expiry: int, amount: int = 1
190
+ ) -> bool:
191
+ """
192
+ Acquire an entry if the weighted count of the current and previous
193
+ windows is less than or equal to the limit
194
+
195
+ :param key: rate limit key to acquire an entry in
196
+ :param limit: amount of entries allowed
197
+ :param expiry: expiry of the entry
198
+ :param amount: the number of entries to acquire
199
+ """
200
+ raise NotImplementedError
201
+
202
+ @abstractmethod
203
+ def get_sliding_window(
204
+ self, key: str, expiry: int
205
+ ) -> tuple[int, float, int, float]:
206
+ """
207
+ Return the previous and current window information.
208
+
209
+ :param key: the rate limit key
210
+ :param expiry: the rate limit expiry, needed to compute the key in some implementations
211
+ :return: a tuple of (int, float, int, float) with the following information:
212
+ - previous window counter
213
+ - previous window TTL
214
+ - current window counter
215
+ - current window TTL
216
+ """
217
+ raise NotImplementedError
218
+
219
+
220
+ class TimestampedSlidingWindow:
221
+ """Helper class for storage that support the sliding window counter, with timestamp based keys."""
222
+
223
+ @classmethod
224
+ def sliding_window_keys(cls, key: str, expiry: int, at: float) -> tuple[str, str]:
225
+ """
226
+ returns the previous and the current window's keys.
227
+
228
+ :param key: the key to get the window's keys from
229
+ :param expiry: the expiry of the limit item, in seconds
230
+ :param at: the timestamp to get the keys from. Default to now, ie ``time.time()``
231
+
232
+ Returns a tuple with the previous and the current key: (previous, current).
233
+
234
+ Example:
235
+ - key = "mykey"
236
+ - expiry = 60
237
+ - at = 1738576292.6631825
238
+
239
+ The return value will be the tuple ``("mykey/28976271", "mykey/28976270")``.
240
+ """
241
+ return f"{key}/{int((at - expiry) / expiry)}", f"{key}/{int(at / expiry)}"
limits/storage/etcd.py CHANGED
@@ -1,9 +1,9 @@
1
1
  import time
2
2
  import urllib.parse
3
- from typing import TYPE_CHECKING, Optional, Tuple, Type, Union
4
3
 
5
4
  from limits.errors import ConcurrentUpdateError
6
5
  from limits.storage.base import Storage
6
+ from limits.typing import TYPE_CHECKING, Optional, Union
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  import etcd3
@@ -26,6 +26,7 @@ class EtcdStorage(Storage):
26
26
  self,
27
27
  uri: str,
28
28
  max_retries: int = MAX_RETRIES,
29
+ wrap_exceptions: bool = False,
29
30
  **options: str,
30
31
  ) -> None:
31
32
  """
@@ -33,6 +34,8 @@ class EtcdStorage(Storage):
33
34
  ``etcd://host:port``,
34
35
  :param max_retries: Maximum number of attempts to retry
35
36
  in the case of concurrent updates to a rate limit key
37
+ :param wrap_exceptions: Whether to wrap storage exceptions in
38
+ :exc:`limits.errors.StorageError` before raising it.
36
39
  :param options: all remaining keyword arguments are passed
37
40
  directly to the constructor of :class:`etcd3.Etcd3Client`
38
41
  :raise ConfigurationError: when :pypi:`etcd3` is not available
@@ -43,11 +46,12 @@ class EtcdStorage(Storage):
43
46
  parsed.hostname, parsed.port, **options
44
47
  )
45
48
  self.max_retries = max_retries
49
+ super().__init__(uri, wrap_exceptions=wrap_exceptions)
46
50
 
47
51
  @property
48
52
  def base_exceptions(
49
53
  self,
50
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
54
+ ) -> Union[type[Exception], tuple[type[Exception], ...]]: # pragma: no cover
51
55
  return self.lib.Etcd3Exception # type: ignore[no-any-return]
52
56
 
53
57
  def prefixed_key(self, key: str) -> bytes: