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/_version.py
CHANGED
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "2025-
|
|
11
|
+
"date": "2025-03-07T12:07:00-0800",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "4.
|
|
14
|
+
"full-revisionid": "aff8ca13e30c9d754690a2931498f023d35dc62f",
|
|
15
|
+
"version": "4.1"
|
|
16
16
|
}
|
|
17
17
|
''' # END VERSION_JSON
|
|
18
18
|
|
limits/aio/storage/__init__.py
CHANGED
|
@@ -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",
|
limits/aio/storage/base.py
CHANGED
|
@@ -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
|
|
34
|
-
if
|
|
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[
|
|
46
|
+
STORAGE_SCHEME: Optional[list[str]]
|
|
48
47
|
"""The storage schemes to register against this implementation"""
|
|
49
48
|
|
|
50
|
-
def
|
|
51
|
-
|
|
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(
|
|
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],
|
|
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
|
|
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
|
|
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
|
-
|
|
145
|
+
cls,
|
|
151
146
|
method,
|
|
152
|
-
_wrap_errors(
|
|
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
|
-
) ->
|
|
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
|
limits/aio/storage/etcd.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import time
|
|
3
3
|
import urllib.parse
|
|
4
|
-
from typing import TYPE_CHECKING, Optional,
|
|
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[
|
|
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:
|
limits/aio/storage/memcached.py
CHANGED
|
@@ -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.
|
|
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],
|
|
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,
|
|
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 =
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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
|