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
limits/__init__.py CHANGED
@@ -2,6 +2,8 @@
2
2
  Rate limiting with commonly used storage backends
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  from . import _version, aio, storage, strategies
6
8
  from .limits import (
7
9
  RateLimitItem,
@@ -30,4 +32,4 @@ __all__ = [
30
32
  "WindowStats",
31
33
  ]
32
34
 
33
- __version__ = _version.get_versions()["version"] # type: ignore
35
+ __version__ = _version.get_versions()["version"]
limits/_version.py CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- # This file was generated by 'versioneer.py' (0.22) from
2
+ # This file was generated by 'versioneer.py' (0.29) from
3
3
  # revision-control system data, or from the parent directory name of an
4
4
  # unpacked source archive. Distribution tarballs contain a pre-generated copy
5
5
  # of this file.
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2025-01-16T11:38:03-0800",
11
+ "date": "2025-03-11T11:27:52-0700",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "074be17ab3008f50de700e996d243ba85580b058",
15
- "version": "4.0.1"
14
+ "full-revisionid": "ef5c0911dd6e0c1a412b3f467d0d1503a2fa24ce",
15
+ "version": "4.2"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
limits/aio/__init__.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from . import storage, strategies
2
4
 
3
5
  __all__ = [
@@ -3,7 +3,9 @@ Implementations of storage backends to be used with
3
3
  :class:`limits.aio.strategies.RateLimiter` strategies
4
4
  """
5
5
 
6
- from .base import MovingWindowSupport, Storage
6
+ from __future__ import annotations
7
+
8
+ from .base import MovingWindowSupport, SlidingWindowCounterSupport, Storage
7
9
  from .etcd import EtcdStorage
8
10
  from .memcached import MemcachedStorage
9
11
  from .memory import MemoryStorage
@@ -13,6 +15,7 @@ from .redis import RedisClusterStorage, RedisSentinelStorage, RedisStorage
13
15
  __all__ = [
14
16
  "Storage",
15
17
  "MovingWindowSupport",
18
+ "SlidingWindowCounterSupport",
16
19
  "EtcdStorage",
17
20
  "MemcachedStorage",
18
21
  "MemoryStorage",
@@ -2,36 +2,35 @@ from __future__ import annotations
2
2
 
3
3
  import functools
4
4
  from abc import ABC, abstractmethod
5
- from typing import Any, cast
6
5
 
7
6
  from deprecated.sphinx import versionadded
8
7
 
9
8
  from limits import errors
10
9
  from limits.storage.registry import StorageRegistry
11
10
  from limits.typing import (
11
+ Any,
12
12
  Awaitable,
13
13
  Callable,
14
- List,
15
14
  Optional,
16
15
  P,
17
16
  R,
18
- Tuple,
19
17
  Type,
20
18
  Union,
19
+ cast,
21
20
  )
22
21
  from limits.util import LazyDependency
23
22
 
24
23
 
25
24
  def _wrap_errors(
26
- storage: Storage,
27
25
  fn: Callable[P, Awaitable[R]],
28
26
  ) -> Callable[P, Awaitable[R]]:
29
27
  @functools.wraps(fn)
30
28
  async def inner(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[misc]
29
+ instance = cast(Storage, args[0])
31
30
  try:
32
31
  return await fn(*args, **kwargs)
33
- except storage.base_exceptions as exc:
34
- if storage.wrap_exceptions:
32
+ except instance.base_exceptions as exc:
33
+ if instance.wrap_exceptions:
35
34
  raise errors.StorageError(exc) from exc
36
35
  raise
37
36
 
@@ -44,12 +43,11 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
44
43
  Base class to extend when implementing an async storage backend.
45
44
  """
46
45
 
47
- STORAGE_SCHEME: Optional[List[str]]
46
+ STORAGE_SCHEME: Optional[list[str]]
48
47
  """The storage schemes to register against this implementation"""
49
48
 
50
- def __new__(cls, *args: Any, **kwargs: Any) -> Storage: # type: ignore[misc]
51
- inst = super().__new__(cls)
52
-
49
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type:ignore[explicit-any]
50
+ super().__init_subclass__(**kwargs)
53
51
  for method in {
54
52
  "incr",
55
53
  "get",
@@ -58,9 +56,8 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
58
56
  "reset",
59
57
  "clear",
60
58
  }:
61
- setattr(inst, method, _wrap_errors(inst, getattr(inst, method)))
62
-
63
- return inst
59
+ setattr(cls, method, _wrap_errors(getattr(cls, method)))
60
+ super().__init_subclass__(**kwargs)
64
61
 
65
62
  def __init__(
66
63
  self,
@@ -77,7 +74,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
77
74
 
78
75
  @property
79
76
  @abstractmethod
80
- def base_exceptions(self) -> Union[Type[Exception], Tuple[Type[Exception], ...]]:
77
+ def base_exceptions(self) -> Union[Type[Exception], tuple[Type[Exception], ...]]:
81
78
  raise NotImplementedError
82
79
 
83
80
  @abstractmethod
@@ -135,24 +132,21 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
135
132
 
136
133
  class MovingWindowSupport(ABC):
137
134
  """
138
- Abstract base for storages that intend to support
139
- the moving window strategy
135
+ Abstract base class for async storages that support
136
+ the :ref:`strategies:moving window` strategy
140
137
  """
141
138
 
142
- def __new__(cls, *args: Any, **kwargs: Any) -> MovingWindowSupport: # type: ignore[misc]
143
- inst = super().__new__(cls)
144
-
139
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
145
140
  for method in {
146
141
  "acquire_entry",
147
142
  "get_moving_window",
148
143
  }:
149
144
  setattr(
150
- inst,
145
+ cls,
151
146
  method,
152
- _wrap_errors(cast(Storage, inst), getattr(inst, method)),
147
+ _wrap_errors(getattr(cls, method)),
153
148
  )
154
-
155
- return inst
149
+ super().__init_subclass__(**kwargs)
156
150
 
157
151
  @abstractmethod
158
152
  async def acquire_entry(
@@ -169,7 +163,7 @@ class MovingWindowSupport(ABC):
169
163
  @abstractmethod
170
164
  async def get_moving_window(
171
165
  self, key: str, limit: int, expiry: int
172
- ) -> Tuple[float, int]:
166
+ ) -> tuple[float, int]:
173
167
  """
174
168
  returns the starting point and the number of entries in the moving
175
169
  window
@@ -179,3 +173,55 @@ class MovingWindowSupport(ABC):
179
173
  :return: (start of window, number of acquired entries)
180
174
  """
181
175
  raise NotImplementedError
176
+
177
+
178
+ class SlidingWindowCounterSupport(ABC):
179
+ """
180
+ Abstract base class for async storages that support
181
+ the :ref:`strategies:sliding window counter` strategy
182
+ """
183
+
184
+ def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
185
+ for method in {"acquire_sliding_window_entry", "get_sliding_window"}:
186
+ setattr(
187
+ cls,
188
+ method,
189
+ _wrap_errors(getattr(cls, method)),
190
+ )
191
+ super().__init_subclass__(**kwargs)
192
+
193
+ @abstractmethod
194
+ async def acquire_sliding_window_entry(
195
+ self,
196
+ key: str,
197
+ limit: int,
198
+ expiry: int,
199
+ amount: int = 1,
200
+ ) -> bool:
201
+ """
202
+ Acquire an entry if the weighted count of the current and previous
203
+ windows is less than or equal to the limit
204
+
205
+ :param key: rate limit key to acquire an entry in
206
+ :param limit: amount of entries allowed
207
+ :param expiry: expiry of the entry
208
+ :param amount: the number of entries to acquire
209
+ """
210
+ raise NotImplementedError
211
+
212
+ @abstractmethod
213
+ async def get_sliding_window(
214
+ self, key: str, expiry: int
215
+ ) -> tuple[int, float, int, float]:
216
+ """
217
+ Return the previous and current window information.
218
+
219
+ :param key: the rate limit key
220
+ :param expiry: the rate limit expiry, needed to compute the key in some implementations
221
+ :return: a tuple of (int, float, int, float) with the following information:
222
+ - previous window counter
223
+ - previous window TTL
224
+ - current window counter
225
+ - current window TTL
226
+ """
227
+ raise NotImplementedError
@@ -1,7 +1,9 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import time
3
5
  import urllib.parse
4
- from typing import TYPE_CHECKING, Optional, Tuple, Type, Union
6
+ from typing import TYPE_CHECKING, Optional, Union
5
7
 
6
8
  from limits.aio.storage.base import Storage
7
9
  from limits.errors import ConcurrentUpdateError
@@ -28,6 +30,7 @@ class EtcdStorage(Storage):
28
30
  self,
29
31
  uri: str,
30
32
  max_retries: int = MAX_RETRIES,
33
+ wrap_exceptions: bool = False,
31
34
  **options: str,
32
35
  ) -> None:
33
36
  """
@@ -35,6 +38,8 @@ class EtcdStorage(Storage):
35
38
  ``async+etcd://host:port``,
36
39
  :param max_retries: Maximum number of attempts to retry
37
40
  in the case of concurrent updates to a rate limit key
41
+ :param wrap_exceptions: Whether to wrap storage exceptions in
42
+ :exc:`limits.errors.StorageError` before raising it.
38
43
  :param options: all remaining keyword arguments are passed
39
44
  directly to the constructor of :class:`aetcd.client.Client`
40
45
  :raise ConfigurationError: when :pypi:`aetcd` is not available
@@ -45,11 +50,12 @@ class EtcdStorage(Storage):
45
50
  host=parsed.hostname, port=parsed.port, **options
46
51
  )
47
52
  self.max_retries = max_retries
53
+ super().__init__(uri, wrap_exceptions=wrap_exceptions)
48
54
 
49
55
  @property
50
56
  def base_exceptions(
51
57
  self,
52
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
58
+ ) -> Union[type[Exception], tuple[type[Exception], ...]]: # pragma: no cover
53
59
  return self.lib.ClientError # type: ignore[no-any-return]
54
60
 
55
61
  def prefixed_key(self, key: str) -> bytes:
@@ -1,14 +1,19 @@
1
+ from __future__ import annotations
2
+
1
3
  import time
2
4
  import urllib.parse
5
+ from collections.abc import Iterable
6
+ from math import ceil, floor
3
7
 
4
8
  from deprecated.sphinx import versionadded
5
9
 
6
- from limits.aio.storage.base import Storage
7
- from limits.typing import EmcacheClientP, Optional, Tuple, Type, Union
10
+ from limits.aio.storage.base import SlidingWindowCounterSupport, Storage
11
+ from limits.storage.base import TimestampedSlidingWindow
12
+ from limits.typing import EmcacheClientP, ItemP, Optional, Type, Union
8
13
 
9
14
 
10
15
  @versionadded(version="2.1")
11
- class MemcachedStorage(Storage):
16
+ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingWindow):
12
17
  """
13
18
  Rate limit storage with memcached as backend.
14
19
 
@@ -51,7 +56,7 @@ class MemcachedStorage(Storage):
51
56
  @property
52
57
  def base_exceptions(
53
58
  self,
54
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
59
+ ) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
55
60
  return (
56
61
  self.dependency.ClusterNoAvailableNodes,
57
62
  self.dependency.CommandError,
@@ -70,19 +75,51 @@ class MemcachedStorage(Storage):
70
75
  """
71
76
  :param key: the key to get the counter value for
72
77
  """
73
-
74
78
  item = await (await self.get_storage()).get(key.encode("utf-8"))
75
79
 
76
80
  return item and int(item.value) or 0
77
81
 
82
+ async def get_many(self, keys: Iterable[str]) -> dict[bytes, ItemP]:
83
+ """
84
+ Return multiple counters at once
85
+
86
+ :param keys: the keys to get the counter values for
87
+ """
88
+ return await (await self.get_storage()).get_many(
89
+ [k.encode("utf-8") for k in keys]
90
+ )
91
+
78
92
  async def clear(self, key: str) -> None:
79
93
  """
80
94
  :param key: the key to clear rate limits for
81
95
  """
82
96
  await (await self.get_storage()).delete(key.encode("utf-8"))
83
97
 
98
+ async def decr(self, key: str, amount: int = 1, noreply: bool = False) -> int:
99
+ """
100
+ decrements the counter for a given rate limit key
101
+
102
+ retursn 0 if the key doesn't exist or if noreply is set to True
103
+
104
+ :param key: the key to decrement
105
+ :param amount: the number to decrement by
106
+ :param noreply: set to True to ignore the memcached response
107
+ """
108
+ storage = await self.get_storage()
109
+ limit_key = key.encode("utf-8")
110
+ try:
111
+ value = await storage.decrement(limit_key, amount, noreply=noreply) or 0
112
+ except self.dependency.NotFoundCommandError:
113
+ value = 0
114
+ return value
115
+
84
116
  async def incr(
85
- self, key: str, expiry: int, elastic_expiry: bool = False, amount: int = 1
117
+ self,
118
+ key: str,
119
+ expiry: float,
120
+ elastic_expiry: bool = False,
121
+ amount: int = 1,
122
+ set_expiration_key: bool = True,
86
123
  ) -> int:
87
124
  """
88
125
  increments the counter for a given rate limit key
@@ -92,49 +129,70 @@ class MemcachedStorage(Storage):
92
129
  :param elastic_expiry: whether to keep extending the rate limit
93
130
  window every hit.
94
131
  :param amount: the number to increment by
132
+ :param set_expiration_key: if set to False, the expiration time won't be stored but the key will still expire
95
133
  """
96
134
  storage = await self.get_storage()
97
135
  limit_key = key.encode("utf-8")
98
- expire_key = f"{key}/expires".encode()
99
- added = True
136
+ expire_key = self._expiration_key(key).encode()
137
+ value = None
100
138
  try:
101
- await storage.add(limit_key, f"{amount}".encode(), exptime=expiry)
102
- except self.dependency.NotStoredStorageCommandError:
103
- added = False
104
- storage = await self.get_storage()
105
-
106
- if not added:
107
139
  value = await storage.increment(limit_key, amount) or amount
108
-
109
140
  if elastic_expiry:
110
- await storage.touch(limit_key, exptime=expiry)
111
- await storage.set(
112
- expire_key,
113
- str(expiry + time.time()).encode("utf-8"),
114
- exptime=expiry,
115
- noreply=False,
116
- )
117
-
141
+ await storage.touch(limit_key, exptime=ceil(expiry))
142
+ if set_expiration_key:
143
+ await storage.set(
144
+ expire_key,
145
+ str(expiry + time.time()).encode("utf-8"),
146
+ exptime=ceil(expiry),
147
+ noreply=False,
148
+ )
149
+ return value
150
+ except self.dependency.NotFoundCommandError:
151
+ # Incrementation failed because the key doesn't exist
152
+ storage = await self.get_storage()
153
+ try:
154
+ await storage.add(limit_key, f"{amount}".encode(), exptime=ceil(expiry))
155
+ if set_expiration_key:
156
+ await storage.set(
157
+ expire_key,
158
+ str(expiry + time.time()).encode("utf-8"),
159
+ exptime=ceil(expiry),
160
+ noreply=False,
161
+ )
162
+ value = amount
163
+ except self.dependency.NotStoredStorageCommandError:
164
+ # Coult not add the key, probably because a concurrent call has added it
165
+ storage = await self.get_storage()
166
+ value = await storage.increment(limit_key, amount) or amount
167
+ if elastic_expiry:
168
+ await storage.touch(limit_key, exptime=ceil(expiry))
169
+ if set_expiration_key:
170
+ await storage.set(
171
+ expire_key,
172
+ str(expiry + time.time()).encode("utf-8"),
173
+ exptime=ceil(expiry),
174
+ noreply=False,
175
+ )
118
176
  return value
119
- else:
120
- await storage.set(
121
- expire_key,
122
- str(expiry + time.time()).encode("utf-8"),
123
- exptime=expiry,
124
- noreply=False,
125
- )
126
-
127
- return amount
128
177
 
129
178
  async def get_expiry(self, key: str) -> float:
130
179
  """
131
180
  :param key: the key to get the expiry for
132
181
  """
133
182
  storage = await self.get_storage()
134
- item = await storage.get(f"{key}/expires".encode())
183
+ item = await storage.get(self._expiration_key(key).encode("utf-8"))
135
184
 
136
185
  return item and float(item.value) or time.time()
137
186
 
187
+ def _expiration_key(self, key: str) -> str:
188
+ """
189
+ Return the expiration key for the given counter key.
190
+
191
+ Memcached doesn't natively return the expiration time or TTL for a given key,
192
+ so we implement the expiration time on a separate key.
193
+ """
194
+ return key + "/expires"
195
+
138
196
  async def check(self) -> bool:
139
197
  """
140
198
  Check if storage is healthy by calling the ``get`` command
@@ -150,3 +208,71 @@ class MemcachedStorage(Storage):
150
208
 
151
209
  async def reset(self) -> Optional[int]:
152
210
  raise NotImplementedError
211
+
212
+ async def acquire_sliding_window_entry(
213
+ self,
214
+ key: str,
215
+ limit: int,
216
+ expiry: int,
217
+ amount: int = 1,
218
+ ) -> bool:
219
+ if amount > limit:
220
+ return False
221
+ now = time.time()
222
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
223
+ (
224
+ previous_count,
225
+ previous_ttl,
226
+ current_count,
227
+ _,
228
+ ) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
229
+ t0 = time.time()
230
+ weighted_count = previous_count * previous_ttl / expiry + current_count
231
+ if floor(weighted_count) + amount > limit:
232
+ return False
233
+ else:
234
+ # Hit, increase the current counter.
235
+ # If the counter doesn't exist yet, set twice the theorical expiry.
236
+ # We don't need the expiration key as it is estimated with the timestamps directly.
237
+ current_count = await self.incr(
238
+ current_key, 2 * expiry, amount=amount, set_expiration_key=False
239
+ )
240
+ t1 = time.time()
241
+ actualised_previous_ttl = max(0, previous_ttl - (t1 - t0))
242
+ weighted_count = (
243
+ previous_count * actualised_previous_ttl / expiry + current_count
244
+ )
245
+ if floor(weighted_count) > limit:
246
+ # Another hit won the race condition: revert the incrementation and refuse this hit
247
+ # Limitation: during high concurrency at the end of the window,
248
+ # the counter is shifted and cannot be decremented, so less requests than expected are allowed.
249
+ await self.decr(current_key, amount, noreply=True)
250
+ return False
251
+ return True
252
+
253
+ async def get_sliding_window(
254
+ self, key: str, expiry: int
255
+ ) -> tuple[int, float, int, float]:
256
+ now = time.time()
257
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
258
+ return await self._get_sliding_window_info(
259
+ previous_key, current_key, expiry, now
260
+ )
261
+
262
+ async def _get_sliding_window_info(
263
+ self, previous_key: str, current_key: str, expiry: int, now: float
264
+ ) -> tuple[int, float, int, float]:
265
+ result = await self.get_many([previous_key, current_key])
266
+
267
+ raw_previous_count = result.get(previous_key.encode("utf-8"))
268
+ raw_current_count = result.get(current_key.encode("utf-8"))
269
+
270
+ current_count = raw_current_count and int(raw_current_count.value) or 0
271
+ previous_count = raw_previous_count and int(raw_previous_count.value) or 0
272
+ if previous_count == 0:
273
+ previous_ttl = float(0)
274
+ else:
275
+ previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
276
+ current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
277
+
278
+ return previous_count, previous_ttl, current_count, current_ttl