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.
limits/_version.py CHANGED
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2025-01-05T13:27:13-0800",
11
+ "date": "2025-03-07T12:07:00-0800",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "61f7d58d4f8588486cfc3b567210604f415878f2",
15
- "version": "4.0.0"
14
+ "full-revisionid": "aff8ca13e30c9d754690a2931498f023d35dc62f",
15
+ "version": "4.1"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -3,7 +3,7 @@ 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 .base import MovingWindowSupport, SlidingWindowCounterSupport, Storage
7
7
  from .etcd import EtcdStorage
8
8
  from .memcached import MemcachedStorage
9
9
  from .memory import MemoryStorage
@@ -13,6 +13,7 @@ from .redis import RedisClusterStorage, RedisSentinelStorage, RedisStorage
13
13
  __all__ = [
14
14
  "Storage",
15
15
  "MovingWindowSupport",
16
+ "SlidingWindowCounterSupport",
16
17
  "EtcdStorage",
17
18
  "MemcachedStorage",
18
19
  "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,7 @@
1
1
  import asyncio
2
2
  import time
3
3
  import urllib.parse
4
- from typing import TYPE_CHECKING, Optional, Tuple, Type, Union
4
+ from typing import TYPE_CHECKING, Optional, Union
5
5
 
6
6
  from limits.aio.storage.base import Storage
7
7
  from limits.errors import ConcurrentUpdateError
@@ -28,6 +28,7 @@ class EtcdStorage(Storage):
28
28
  self,
29
29
  uri: str,
30
30
  max_retries: int = MAX_RETRIES,
31
+ wrap_exceptions: bool = False,
31
32
  **options: str,
32
33
  ) -> None:
33
34
  """
@@ -35,6 +36,8 @@ class EtcdStorage(Storage):
35
36
  ``async+etcd://host:port``,
36
37
  :param max_retries: Maximum number of attempts to retry
37
38
  in the case of concurrent updates to a rate limit key
39
+ :param wrap_exceptions: Whether to wrap storage exceptions in
40
+ :exc:`limits.errors.StorageError` before raising it.
38
41
  :param options: all remaining keyword arguments are passed
39
42
  directly to the constructor of :class:`aetcd.client.Client`
40
43
  :raise ConfigurationError: when :pypi:`aetcd` is not available
@@ -45,11 +48,12 @@ class EtcdStorage(Storage):
45
48
  host=parsed.hostname, port=parsed.port, **options
46
49
  )
47
50
  self.max_retries = max_retries
51
+ super().__init__(uri, wrap_exceptions=wrap_exceptions)
48
52
 
49
53
  @property
50
54
  def base_exceptions(
51
55
  self,
52
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
56
+ ) -> Union[type[Exception], tuple[type[Exception], ...]]: # pragma: no cover
53
57
  return self.lib.ClientError # type: ignore[no-any-return]
54
58
 
55
59
  def prefixed_key(self, key: str) -> bytes:
@@ -1,14 +1,17 @@
1
1
  import time
2
2
  import urllib.parse
3
+ from collections.abc import Iterable
4
+ from math import ceil, floor
3
5
 
4
6
  from deprecated.sphinx import versionadded
5
7
 
6
- from limits.aio.storage.base import Storage
7
- from limits.typing import EmcacheClientP, Optional, Tuple, Type, Union
8
+ from limits.aio.storage.base import SlidingWindowCounterSupport, Storage
9
+ from limits.storage.base import TimestampedSlidingWindow
10
+ from limits.typing import EmcacheClientP, ItemP, Optional, Type, Union
8
11
 
9
12
 
10
13
  @versionadded(version="2.1")
11
- class MemcachedStorage(Storage):
14
+ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingWindow):
12
15
  """
13
16
  Rate limit storage with memcached as backend.
14
17
 
@@ -51,7 +54,7 @@ class MemcachedStorage(Storage):
51
54
  @property
52
55
  def base_exceptions(
53
56
  self,
54
- ) -> Union[Type[Exception], Tuple[Type[Exception], ...]]: # pragma: no cover
57
+ ) -> Union[Type[Exception], tuple[Type[Exception], ...]]: # pragma: no cover
55
58
  return (
56
59
  self.dependency.ClusterNoAvailableNodes,
57
60
  self.dependency.CommandError,
@@ -70,19 +73,51 @@ class MemcachedStorage(Storage):
70
73
  """
71
74
  :param key: the key to get the counter value for
72
75
  """
73
-
74
76
  item = await (await self.get_storage()).get(key.encode("utf-8"))
75
77
 
76
78
  return item and int(item.value) or 0
77
79
 
80
+ async def get_many(self, keys: Iterable[str]) -> dict[bytes, ItemP]:
81
+ """
82
+ Return multiple counters at once
83
+
84
+ :param keys: the keys to get the counter values for
85
+ """
86
+ return await (await self.get_storage()).get_many(
87
+ [k.encode("utf-8") for k in keys]
88
+ )
89
+
78
90
  async def clear(self, key: str) -> None:
79
91
  """
80
92
  :param key: the key to clear rate limits for
81
93
  """
82
94
  await (await self.get_storage()).delete(key.encode("utf-8"))
83
95
 
96
+ async def decr(self, key: str, amount: int = 1, noreply: bool = False) -> int:
97
+ """
98
+ decrements the counter for a given rate limit key
99
+
100
+ retursn 0 if the key doesn't exist or if noreply is set to True
101
+
102
+ :param key: the key to decrement
103
+ :param amount: the number to decrement by
104
+ :param noreply: set to True to ignore the memcached response
105
+ """
106
+ storage = await self.get_storage()
107
+ limit_key = key.encode("utf-8")
108
+ try:
109
+ value = await storage.decrement(limit_key, amount, noreply=noreply) or 0
110
+ except self.dependency.NotFoundCommandError:
111
+ value = 0
112
+ return value
113
+
84
114
  async def incr(
85
- self, key: str, expiry: int, elastic_expiry: bool = False, amount: int = 1
115
+ self,
116
+ key: str,
117
+ expiry: float,
118
+ elastic_expiry: bool = False,
119
+ amount: int = 1,
120
+ set_expiration_key: bool = True,
86
121
  ) -> int:
87
122
  """
88
123
  increments the counter for a given rate limit key
@@ -92,49 +127,70 @@ class MemcachedStorage(Storage):
92
127
  :param elastic_expiry: whether to keep extending the rate limit
93
128
  window every hit.
94
129
  :param amount: the number to increment by
130
+ :param set_expiration_key: if set to False, the expiration time won't be stored but the key will still expire
95
131
  """
96
132
  storage = await self.get_storage()
97
133
  limit_key = key.encode("utf-8")
98
- expire_key = f"{key}/expires".encode()
99
- added = True
134
+ expire_key = self._expiration_key(key).encode()
135
+ value = None
100
136
  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
137
  value = await storage.increment(limit_key, amount) or amount
108
-
109
138
  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
-
139
+ await storage.touch(limit_key, exptime=ceil(expiry))
140
+ if set_expiration_key:
141
+ await storage.set(
142
+ expire_key,
143
+ str(expiry + time.time()).encode("utf-8"),
144
+ exptime=ceil(expiry),
145
+ noreply=False,
146
+ )
147
+ return value
148
+ except self.dependency.NotFoundCommandError:
149
+ # Incrementation failed because the key doesn't exist
150
+ storage = await self.get_storage()
151
+ try:
152
+ await storage.add(limit_key, f"{amount}".encode(), exptime=ceil(expiry))
153
+ if set_expiration_key:
154
+ await storage.set(
155
+ expire_key,
156
+ str(expiry + time.time()).encode("utf-8"),
157
+ exptime=ceil(expiry),
158
+ noreply=False,
159
+ )
160
+ value = amount
161
+ except self.dependency.NotStoredStorageCommandError:
162
+ # Coult not add the key, probably because a concurrent call has added it
163
+ storage = await self.get_storage()
164
+ value = await storage.increment(limit_key, amount) or amount
165
+ if elastic_expiry:
166
+ await storage.touch(limit_key, exptime=ceil(expiry))
167
+ if set_expiration_key:
168
+ await storage.set(
169
+ expire_key,
170
+ str(expiry + time.time()).encode("utf-8"),
171
+ exptime=ceil(expiry),
172
+ noreply=False,
173
+ )
118
174
  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
175
 
129
176
  async def get_expiry(self, key: str) -> float:
130
177
  """
131
178
  :param key: the key to get the expiry for
132
179
  """
133
180
  storage = await self.get_storage()
134
- item = await storage.get(f"{key}/expires".encode())
181
+ item = await storage.get(self._expiration_key(key).encode("utf-8"))
135
182
 
136
183
  return item and float(item.value) or time.time()
137
184
 
185
+ def _expiration_key(self, key: str) -> str:
186
+ """
187
+ Return the expiration key for the given counter key.
188
+
189
+ Memcached doesn't natively return the expiration time or TTL for a given key,
190
+ so we implement the expiration time on a separate key.
191
+ """
192
+ return key + "/expires"
193
+
138
194
  async def check(self) -> bool:
139
195
  """
140
196
  Check if storage is healthy by calling the ``get`` command
@@ -150,3 +206,71 @@ class MemcachedStorage(Storage):
150
206
 
151
207
  async def reset(self) -> Optional[int]:
152
208
  raise NotImplementedError
209
+
210
+ async def acquire_sliding_window_entry(
211
+ self,
212
+ key: str,
213
+ limit: int,
214
+ expiry: int,
215
+ amount: int = 1,
216
+ ) -> bool:
217
+ if amount > limit:
218
+ return False
219
+ now = time.time()
220
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
221
+ (
222
+ previous_count,
223
+ previous_ttl,
224
+ current_count,
225
+ _,
226
+ ) = await self._get_sliding_window_info(previous_key, current_key, expiry, now)
227
+ t0 = time.time()
228
+ weighted_count = previous_count * previous_ttl / expiry + current_count
229
+ if floor(weighted_count) + amount > limit:
230
+ return False
231
+ else:
232
+ # Hit, increase the current counter.
233
+ # If the counter doesn't exist yet, set twice the theorical expiry.
234
+ # We don't need the expiration key as it is estimated with the timestamps directly.
235
+ current_count = await self.incr(
236
+ current_key, 2 * expiry, amount=amount, set_expiration_key=False
237
+ )
238
+ t1 = time.time()
239
+ actualised_previous_ttl = max(0, previous_ttl - (t1 - t0))
240
+ weighted_count = (
241
+ previous_count * actualised_previous_ttl / expiry + current_count
242
+ )
243
+ if floor(weighted_count) > limit:
244
+ # Another hit won the race condition: revert the incrementation and refuse this hit
245
+ # Limitation: during high concurrency at the end of the window,
246
+ # the counter is shifted and cannot be decremented, so less requests than expected are allowed.
247
+ await self.decr(current_key, amount, noreply=True)
248
+ return False
249
+ return True
250
+
251
+ async def get_sliding_window(
252
+ self, key: str, expiry: int
253
+ ) -> tuple[int, float, int, float]:
254
+ now = time.time()
255
+ previous_key, current_key = self.sliding_window_keys(key, expiry, now)
256
+ return await self._get_sliding_window_info(
257
+ previous_key, current_key, expiry, now
258
+ )
259
+
260
+ async def _get_sliding_window_info(
261
+ self, previous_key: str, current_key: str, expiry: int, now: float
262
+ ) -> tuple[int, float, int, float]:
263
+ result = await self.get_many([previous_key, current_key])
264
+
265
+ raw_previous_count = result.get(previous_key.encode("utf-8"))
266
+ raw_current_count = result.get(current_key.encode("utf-8"))
267
+
268
+ current_count = raw_current_count and int(raw_current_count.value) or 0
269
+ previous_count = raw_previous_count and int(raw_previous_count.value) or 0
270
+ if previous_count == 0:
271
+ previous_ttl = float(0)
272
+ else:
273
+ previous_ttl = (1 - (((now - expiry) / expiry) % 1)) * expiry
274
+ current_ttl = (1 - ((now / expiry) % 1)) * expiry + expiry
275
+
276
+ return previous_count, previous_ttl, current_count, current_ttl