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/_version.py +3 -3
- limits/aio/storage/__init__.py +2 -1
- limits/aio/storage/base.py +70 -24
- limits/aio/storage/etcd.py +6 -2
- limits/aio/storage/memcached.py +157 -33
- limits/aio/storage/memory.py +98 -13
- limits/aio/storage/mongodb.py +217 -9
- limits/aio/storage/redis.py +100 -15
- limits/aio/strategies.py +122 -1
- limits/limits.py +10 -11
- limits/resources/redis/lua_scripts/acquire_sliding_window.lua +45 -0
- limits/resources/redis/lua_scripts/sliding_window.lua +17 -0
- limits/storage/__init__.py +4 -3
- limits/storage/base.py +92 -24
- limits/storage/etcd.py +6 -2
- limits/storage/memcached.py +141 -34
- limits/storage/memory.py +97 -12
- limits/storage/mongodb.py +204 -11
- limits/storage/redis.py +159 -138
- limits/storage/redis_cluster.py +3 -3
- limits/storage/redis_sentinel.py +12 -35
- limits/storage/registry.py +3 -3
- limits/strategies.py +119 -5
- limits/typing.py +43 -15
- limits/util.py +27 -18
- limits-4.1.dist-info/METADATA +268 -0
- limits-4.1.dist-info/RECORD +39 -0
- limits-4.0.1.dist-info/METADATA +0 -192
- limits-4.0.1.dist-info/RECORD +0 -37
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/LICENSE.txt +0 -0
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/WHEEL +0 -0
- {limits-4.0.1.dist-info → limits-4.1.dist-info}/top_level.txt +0 -0
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
|
|
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,
|
|
7
|
+
from limits.typing import ClassVar, NamedTuple, cast
|
|
9
8
|
|
|
10
9
|
|
|
11
|
-
def safe_string(value:
|
|
10
|
+
def safe_string(value: bytes | str | int | float) -> str:
|
|
12
11
|
"""
|
|
13
|
-
|
|
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:
|
|
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:
|
|
44
|
-
dct:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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}
|
limits/storage/__init__.py
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
29
|
-
if
|
|
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[
|
|
43
|
+
STORAGE_SCHEME: Optional[list[str]]
|
|
42
44
|
"""The storage schemes to register against this implementation"""
|
|
43
45
|
|
|
44
|
-
def
|
|
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(
|
|
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],
|
|
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
|
|
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
|
|
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
|
-
|
|
143
|
+
cls,
|
|
147
144
|
method,
|
|
148
|
-
_wrap_errors(
|
|
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) ->
|
|
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[
|
|
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:
|