limits 5.0.0rc2__tar.gz → 5.2.0__tar.gz
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-5.0.0rc2 → limits-5.2.0}/HISTORY.rst +27 -14
- {limits-5.0.0rc2 → limits-5.2.0}/PKG-INFO +2 -2
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/theme_config.py +0 -1
- {limits-5.0.0rc2 → limits-5.2.0}/limits/_version.py +3 -3
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/base.py +15 -1
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/__init__.py +6 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memory.py +6 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/mongodb.py +3 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/__init__.py +29 -6
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/bridge.py +3 -2
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/strategies.py +5 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/base.py +15 -1
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/memcached.py +6 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/memory.py +6 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/mongodb.py +3 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis.py +11 -2
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis_cluster.py +5 -2
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/redis_sentinel.py +3 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/strategies.py +5 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/PKG-INFO +2 -2
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/requires.txt +1 -1
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/main.txt +1 -1
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_storage.py +12 -0
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_strategy.py +3 -0
- {limits-5.0.0rc2 → limits-5.2.0}/CLASSIFIERS +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/CONTRIBUTIONS.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/LICENSE.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/MANIFEST.in +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/README.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/Makefile +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/_static/custom.css +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/api.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/async.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/changelog.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/conf.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/custom-storage.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/benchmark-chart.css +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-chart.js +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-details.js +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_static/js/benchmark-loader.js +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/_templates/git_info.js +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/ext/bench_chart.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/index.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/installation.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/performance.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/quickstart.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/storage.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/doc/source/strategies.rst +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/__init__.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/__init__.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/__init__.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/bridge.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/emcache.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/memcached/memcachio.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/coredis.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/redispy.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/aio/storage/redis/valkey.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/errors.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/limits.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/py.typed +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_moving_window.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_sliding_window.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/clear_keys.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/incr_expire.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/moving_window.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/sliding_window.lua +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/__init__.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/storage/registry.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/typing.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/util.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits/version.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/SOURCES.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/dependency_links.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/not-zip-safe +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/limits.egg-info/top_level.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/pyproject.toml +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/ci.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/dev.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/docs.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-memcached.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-mongodb.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-redis.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/async-valkey.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/memcached.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/mongodb.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/redis.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/rediscluster.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/storage/valkey.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/requirements/test.txt +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/setup.cfg +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/setup.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_limit_granularities.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_limits.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_ratelimit_parser.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/tests/test_utils.py +0 -0
- {limits-5.0.0rc2 → limits-5.2.0}/versioneer.py +0 -0
|
@@ -3,35 +3,45 @@
|
|
|
3
3
|
Changelog
|
|
4
4
|
=========
|
|
5
5
|
|
|
6
|
-
v5.0
|
|
7
|
-
|
|
8
|
-
Release Date: 2025-
|
|
6
|
+
v5.2.0
|
|
7
|
+
------
|
|
8
|
+
Release Date: 2025-05-16
|
|
9
9
|
|
|
10
|
-
*
|
|
10
|
+
* Bug Fix
|
|
11
11
|
|
|
12
|
-
*
|
|
13
|
-
|
|
12
|
+
* Fix incorrect behavior of the ``clear`` method for sliding window
|
|
13
|
+
counter which effectively did not clear the sliding window for
|
|
14
|
+
redis, memcached & in memory storage implementations.
|
|
15
|
+
`Issue 276 <https://github.com/alisaifee/limits/issues/276>`_
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
v5.1.0
|
|
18
|
+
------
|
|
19
|
+
Release Date: 2025-04-23
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
* Features
|
|
18
22
|
|
|
23
|
+
* Expose ``key_prefix`` constructor argument for all redis storage
|
|
24
|
+
implementations to simplify customizing the prefix used for all
|
|
25
|
+
keys created in redis.
|
|
19
26
|
|
|
20
|
-
v5.0.
|
|
21
|
-
|
|
22
|
-
Release Date: 2025-04-
|
|
27
|
+
v5.0.0
|
|
28
|
+
------
|
|
29
|
+
Release Date: 2025-04-15
|
|
23
30
|
|
|
24
31
|
* Backward incompatible changes
|
|
25
32
|
|
|
26
33
|
* Dropped support for Fixed Window with Elastic Expiry strategy
|
|
27
34
|
* Dropped support for etcd
|
|
28
|
-
*
|
|
35
|
+
* Changed the default implementation for async+memached from :pypi:`emcache`
|
|
36
|
+
to :pypi`:memcachio`
|
|
29
37
|
|
|
30
38
|
* Performance
|
|
31
39
|
|
|
32
|
-
* Improved performance of
|
|
33
|
-
|
|
40
|
+
* Improved performance of redis moving window ``test`` and ``get_window_stats`` operations
|
|
41
|
+
especially when dealing with large rate limits.
|
|
34
42
|
* Improved performance of mongodb moving window ``test`` and ``get_window_stats`` operations.
|
|
43
|
+
* Improved performance of in-memory moving window ``test`` and ``get_window_stats`` operations.
|
|
44
|
+
* Reduced load on event loop when expiring limits with async in-memory implementations
|
|
35
45
|
|
|
36
46
|
v4.7.3
|
|
37
47
|
------
|
|
@@ -835,3 +845,6 @@ Release Date: 2015-01-08
|
|
|
835
845
|
|
|
836
846
|
* Initial import of common rate limiting code from `Flask-Limiter <https://github.com/alisaifee/flask-limiter>`_
|
|
837
847
|
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: limits
|
|
3
|
-
Version: 5.0
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Rate limiting utilities
|
|
5
5
|
Home-page: https://limits.readthedocs.org
|
|
6
6
|
Author: Ali-Akber Saifee
|
|
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
License-File: LICENSE.txt
|
|
24
24
|
Requires-Dist: deprecated>=1.2
|
|
25
|
-
Requires-Dist: packaging<
|
|
25
|
+
Requires-Dist: packaging<26,>=21
|
|
26
26
|
Requires-Dist: typing_extensions
|
|
27
27
|
Provides-Extra: redis
|
|
28
28
|
Requires-Dist: redis!=4.5.2,!=4.5.3,<6.0.0,>3; extra == "redis"
|
|
@@ -8,11 +8,11 @@ import json
|
|
|
8
8
|
|
|
9
9
|
version_json = '''
|
|
10
10
|
{
|
|
11
|
-
"date": "2025-
|
|
11
|
+
"date": "2025-05-16T12:15:52-0700",
|
|
12
12
|
"dirty": false,
|
|
13
13
|
"error": null,
|
|
14
|
-
"full-revisionid": "
|
|
15
|
-
"version": "5.0
|
|
14
|
+
"full-revisionid": "0c8d73757f54788d5fa213a678dfbdf3fdd7ccfb",
|
|
15
|
+
"version": "5.2.0"
|
|
16
16
|
}
|
|
17
17
|
''' # END VERSION_JSON
|
|
18
18
|
|
|
@@ -175,7 +175,11 @@ class SlidingWindowCounterSupport(ABC):
|
|
|
175
175
|
"""
|
|
176
176
|
|
|
177
177
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
178
|
-
for method in {
|
|
178
|
+
for method in {
|
|
179
|
+
"acquire_sliding_window_entry",
|
|
180
|
+
"get_sliding_window",
|
|
181
|
+
"clear_sliding_window",
|
|
182
|
+
}:
|
|
179
183
|
setattr(
|
|
180
184
|
cls,
|
|
181
185
|
method,
|
|
@@ -218,3 +222,13 @@ class SlidingWindowCounterSupport(ABC):
|
|
|
218
222
|
- current window TTL
|
|
219
223
|
"""
|
|
220
224
|
raise NotImplementedError
|
|
225
|
+
|
|
226
|
+
@abstractmethod
|
|
227
|
+
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
228
|
+
"""
|
|
229
|
+
Resets the rate limit key(s) for the sliding window
|
|
230
|
+
|
|
231
|
+
:param key: the key to clear rate limits for
|
|
232
|
+
:param expiry: the rate limit expiry, needed to compute the key in some implemenations
|
|
233
|
+
"""
|
|
234
|
+
...
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import time
|
|
4
5
|
from math import floor
|
|
5
6
|
|
|
@@ -167,6 +168,11 @@ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingW
|
|
|
167
168
|
previous_key, current_key, expiry, now
|
|
168
169
|
)
|
|
169
170
|
|
|
171
|
+
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
172
|
+
now = time.time()
|
|
173
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
174
|
+
await asyncio.gather(self.clear(previous_key), self.clear(current_key))
|
|
175
|
+
|
|
170
176
|
async def _get_sliding_window_info(
|
|
171
177
|
self, previous_key: str, current_key: str, expiry: int, now: float
|
|
172
178
|
) -> tuple[int, float, int, float]:
|
|
@@ -241,6 +241,12 @@ class MemoryStorage(
|
|
|
241
241
|
previous_key, current_key, expiry, now
|
|
242
242
|
)
|
|
243
243
|
|
|
244
|
+
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
245
|
+
now = time.time()
|
|
246
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
247
|
+
await self.clear(current_key)
|
|
248
|
+
await self.clear(previous_key)
|
|
249
|
+
|
|
244
250
|
async def _get_sliding_window_info(
|
|
245
251
|
self,
|
|
246
252
|
previous_key: str,
|
|
@@ -513,5 +513,8 @@ class MongoDBStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
513
513
|
)
|
|
514
514
|
return 0, 0.0, 0, 0.0
|
|
515
515
|
|
|
516
|
+
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
517
|
+
return await self.clear(key)
|
|
518
|
+
|
|
516
519
|
def __del__(self) -> None:
|
|
517
520
|
self.storage and self.storage.close()
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
3
5
|
from deprecated.sphinx import versionadded, versionchanged
|
|
4
6
|
from packaging.version import Version
|
|
5
7
|
|
|
@@ -51,6 +53,8 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
51
53
|
"valkey": Version("6.0"),
|
|
52
54
|
}
|
|
53
55
|
MODE: Literal["BASIC", "CLUSTER", "SENTINEL"] = "BASIC"
|
|
56
|
+
PREFIX = "LIMITS"
|
|
57
|
+
|
|
54
58
|
bridge: RedisBridge
|
|
55
59
|
storage_exceptions: tuple[Exception, ...]
|
|
56
60
|
target_server: Literal["redis", "valkey"]
|
|
@@ -60,6 +64,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
60
64
|
uri: str,
|
|
61
65
|
wrap_exceptions: bool = False,
|
|
62
66
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
|
67
|
+
key_prefix: str = PREFIX,
|
|
63
68
|
**options: float | str | bool,
|
|
64
69
|
) -> None:
|
|
65
70
|
"""
|
|
@@ -86,6 +91,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
86
91
|
- ``redispy``: :class:`redis.asyncio.client.Redis`
|
|
87
92
|
- ``valkey``: :class:`valkey.asyncio.client.Valkey`
|
|
88
93
|
|
|
94
|
+
:param key_prefix: the prefix for each key created in redis
|
|
89
95
|
:param options: all remaining keyword arguments are passed
|
|
90
96
|
directly to the constructor of :class:`coredis.Redis` or :class:`redis.asyncio.client.Redis`
|
|
91
97
|
:raise ConfigurationError: when the redis library is not available
|
|
@@ -97,12 +103,18 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
97
103
|
super().__init__(uri, wrap_exceptions=wrap_exceptions)
|
|
98
104
|
self.options = options
|
|
99
105
|
if self.target_server == "valkey" or implementation == "valkey":
|
|
100
|
-
self.bridge = ValkeyBridge(
|
|
106
|
+
self.bridge = ValkeyBridge(
|
|
107
|
+
uri, self.dependencies["valkey"].module, key_prefix
|
|
108
|
+
)
|
|
101
109
|
else:
|
|
102
110
|
if implementation == "redispy":
|
|
103
|
-
self.bridge = RedispyBridge(
|
|
111
|
+
self.bridge = RedispyBridge(
|
|
112
|
+
uri, self.dependencies["redis"].module, key_prefix
|
|
113
|
+
)
|
|
104
114
|
else:
|
|
105
|
-
self.bridge = CoredisBridge(
|
|
115
|
+
self.bridge = CoredisBridge(
|
|
116
|
+
uri, self.dependencies["coredis"].module, key_prefix
|
|
117
|
+
)
|
|
106
118
|
self.configure_bridge()
|
|
107
119
|
self.bridge.register_scripts()
|
|
108
120
|
|
|
@@ -209,6 +221,11 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
209
221
|
current_key = self._current_window_key(key)
|
|
210
222
|
return await self.bridge.get_sliding_window(previous_key, current_key, expiry)
|
|
211
223
|
|
|
224
|
+
async def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
225
|
+
previous_key = self._previous_window_key(key)
|
|
226
|
+
current_key = self._current_window_key(key)
|
|
227
|
+
await asyncio.gather(self.clear(previous_key), self.clear(current_key))
|
|
228
|
+
|
|
212
229
|
async def get_expiry(self, key: str) -> float:
|
|
213
230
|
"""
|
|
214
231
|
:param key: the key to get the expiry for
|
|
@@ -226,7 +243,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
226
243
|
async def reset(self) -> int | None:
|
|
227
244
|
"""
|
|
228
245
|
This function calls a Lua Script to delete keys prefixed with
|
|
229
|
-
|
|
246
|
+
:paramref:`RedisStorage.key_prefix` in blocks of 5000.
|
|
230
247
|
|
|
231
248
|
.. warning:: This operation was designed to be fast, but was not tested
|
|
232
249
|
on a large production based system. Be careful with its usage as it
|
|
@@ -268,6 +285,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
268
285
|
uri: str,
|
|
269
286
|
wrap_exceptions: bool = False,
|
|
270
287
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
|
288
|
+
key_prefix: str = RedisStorage.PREFIX,
|
|
271
289
|
**options: float | str | bool,
|
|
272
290
|
) -> None:
|
|
273
291
|
"""
|
|
@@ -283,6 +301,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
283
301
|
- ``coredis``: :class:`coredis.RedisCluster`
|
|
284
302
|
- ``redispy``: :class:`redis.asyncio.cluster.RedisCluster`
|
|
285
303
|
- ``valkey``: :class:`valkey.asyncio.cluster.ValkeyCluster`
|
|
304
|
+
:param key_prefix: the prefix for each key created in redis
|
|
286
305
|
:param options: all remaining keyword arguments are passed
|
|
287
306
|
directly to the constructor of :class:`coredis.RedisCluster` or
|
|
288
307
|
:class:`redis.asyncio.RedisCluster`
|
|
@@ -293,6 +312,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
293
312
|
uri,
|
|
294
313
|
wrap_exceptions=wrap_exceptions,
|
|
295
314
|
implementation=implementation,
|
|
315
|
+
key_prefix=key_prefix,
|
|
296
316
|
**options,
|
|
297
317
|
)
|
|
298
318
|
|
|
@@ -303,8 +323,8 @@ class RedisClusterStorage(RedisStorage):
|
|
|
303
323
|
"""
|
|
304
324
|
Redis Clusters are sharded and deleting across shards
|
|
305
325
|
can't be done atomically. Because of this, this reset loops over all
|
|
306
|
-
keys that are prefixed with
|
|
307
|
-
one at a time.
|
|
326
|
+
keys that are prefixed with :paramref:`RedisClusterStorage.key_prefix`
|
|
327
|
+
and calls delete on them one at a time.
|
|
308
328
|
|
|
309
329
|
.. warning:: This operation was not tested with extremely large data sets.
|
|
310
330
|
On a large production based system, care should be taken with its
|
|
@@ -354,6 +374,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
354
374
|
uri: str,
|
|
355
375
|
wrap_exceptions: bool = False,
|
|
356
376
|
implementation: Literal["redispy", "coredis", "valkey"] = "coredis",
|
|
377
|
+
key_prefix: str = RedisStorage.PREFIX,
|
|
357
378
|
service_name: str | None = None,
|
|
358
379
|
use_replicas: bool = True,
|
|
359
380
|
sentinel_kwargs: dict[str, float | str | bool] | None = None,
|
|
@@ -372,6 +393,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
372
393
|
- ``coredis``: :class:`coredis.sentinel.Sentinel`
|
|
373
394
|
- ``redispy``: :class:`redis.asyncio.sentinel.Sentinel`
|
|
374
395
|
- ``valkey``: :class:`valkey.asyncio.sentinel.Sentinel`
|
|
396
|
+
:param key_prefix: the prefix for each key created in redis
|
|
375
397
|
:param service_name: sentinel service name (if not provided in `uri`)
|
|
376
398
|
:param use_replicas: Whether to use replicas for read only operations
|
|
377
399
|
:param sentinel_kwargs: optional arguments to pass as
|
|
@@ -391,6 +413,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
391
413
|
uri,
|
|
392
414
|
wrap_exceptions=wrap_exceptions,
|
|
393
415
|
implementation=implementation,
|
|
416
|
+
key_prefix=key_prefix,
|
|
394
417
|
**options,
|
|
395
418
|
)
|
|
396
419
|
|
|
@@ -8,7 +8,6 @@ from limits.util import get_package_data
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class RedisBridge(ABC):
|
|
11
|
-
PREFIX = "LIMITS"
|
|
12
11
|
RES_DIR = "resources/redis/lua_scripts"
|
|
13
12
|
|
|
14
13
|
SCRIPT_MOVING_WINDOW = get_package_data(f"{RES_DIR}/moving_window.lua")
|
|
@@ -26,18 +25,20 @@ class RedisBridge(ABC):
|
|
|
26
25
|
self,
|
|
27
26
|
uri: str,
|
|
28
27
|
dependency: ModuleType,
|
|
28
|
+
key_prefix: str,
|
|
29
29
|
) -> None:
|
|
30
30
|
self.uri = uri
|
|
31
31
|
self.parsed_uri = urllib.parse.urlparse(self.uri)
|
|
32
32
|
self.dependency = dependency
|
|
33
33
|
self.parsed_auth = {}
|
|
34
|
+
self.key_prefix = key_prefix
|
|
34
35
|
if self.parsed_uri.username:
|
|
35
36
|
self.parsed_auth["username"] = self.parsed_uri.username
|
|
36
37
|
if self.parsed_uri.password:
|
|
37
38
|
self.parsed_auth["password"] = self.parsed_uri.password
|
|
38
39
|
|
|
39
40
|
def prefixed_key(self, key: str) -> str:
|
|
40
|
-
return f"{self.
|
|
41
|
+
return f"{self.key_prefix}:{key}"
|
|
41
42
|
|
|
42
43
|
@abstractmethod
|
|
43
44
|
def register_scripts(self) -> None: ...
|
|
@@ -302,6 +302,11 @@ class SlidingWindowCounterRateLimiter(RateLimiter):
|
|
|
302
302
|
|
|
303
303
|
return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
|
|
304
304
|
|
|
305
|
+
async def clear(self, item: RateLimitItem, *identifiers: str) -> None:
|
|
306
|
+
return await cast(
|
|
307
|
+
SlidingWindowCounterSupport, self.storage
|
|
308
|
+
).clear_sliding_window(item.key_for(*identifiers), item.get_expiry())
|
|
309
|
+
|
|
305
310
|
|
|
306
311
|
STRATEGIES = {
|
|
307
312
|
"sliding-window-counter": SlidingWindowCounterRateLimiter,
|
|
@@ -167,7 +167,11 @@ class SlidingWindowCounterSupport(ABC):
|
|
|
167
167
|
"""
|
|
168
168
|
|
|
169
169
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
170
|
-
for method in {
|
|
170
|
+
for method in {
|
|
171
|
+
"acquire_sliding_window_entry",
|
|
172
|
+
"get_sliding_window",
|
|
173
|
+
"clear_sliding_window",
|
|
174
|
+
}:
|
|
171
175
|
setattr(
|
|
172
176
|
cls,
|
|
173
177
|
method,
|
|
@@ -207,6 +211,16 @@ class SlidingWindowCounterSupport(ABC):
|
|
|
207
211
|
"""
|
|
208
212
|
raise NotImplementedError
|
|
209
213
|
|
|
214
|
+
@abstractmethod
|
|
215
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
216
|
+
"""
|
|
217
|
+
Resets the rate limit key(s) for the sliding window
|
|
218
|
+
|
|
219
|
+
:param key: the key to clear rate limits for
|
|
220
|
+
:param expiry: the rate limit expiry, needed to compute the key in some implemenations
|
|
221
|
+
"""
|
|
222
|
+
...
|
|
223
|
+
|
|
210
224
|
|
|
211
225
|
class TimestampedSlidingWindow:
|
|
212
226
|
"""Helper class for storage that support the sliding window counter, with timestamp based keys."""
|
|
@@ -282,6 +282,12 @@ class MemcachedStorage(Storage, SlidingWindowCounterSupport, TimestampedSlidingW
|
|
|
282
282
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
283
283
|
return self._get_sliding_window_info(previous_key, current_key, expiry, now)
|
|
284
284
|
|
|
285
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
286
|
+
now = time.time()
|
|
287
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
288
|
+
self.clear(previous_key)
|
|
289
|
+
self.clear(current_key)
|
|
290
|
+
|
|
285
291
|
def _get_sliding_window_info(
|
|
286
292
|
self, previous_key: str, current_key: str, expiry: int, now: float
|
|
287
293
|
) -> tuple[int, float, int, float]:
|
|
@@ -237,6 +237,12 @@ class MemoryStorage(
|
|
|
237
237
|
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
238
238
|
return self._get_sliding_window_info(previous_key, current_key, expiry, now)
|
|
239
239
|
|
|
240
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
241
|
+
now = time.time()
|
|
242
|
+
previous_key, current_key = self.sliding_window_keys(key, expiry, now)
|
|
243
|
+
self.clear(previous_key)
|
|
244
|
+
self.clear(current_key)
|
|
245
|
+
|
|
240
246
|
def check(self) -> bool:
|
|
241
247
|
"""
|
|
242
248
|
check if storage is healthy
|
|
@@ -470,6 +470,9 @@ class MongoDBStorageBase(
|
|
|
470
470
|
)
|
|
471
471
|
return cast(bool, result["_acquired"])
|
|
472
472
|
|
|
473
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
474
|
+
return self.clear(key)
|
|
475
|
+
|
|
473
476
|
def __del__(self) -> None:
|
|
474
477
|
if self.storage:
|
|
475
478
|
self.storage.close()
|
|
@@ -68,6 +68,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
68
68
|
self,
|
|
69
69
|
uri: str,
|
|
70
70
|
connection_pool: redis.connection.ConnectionPool | None = None,
|
|
71
|
+
key_prefix: str = PREFIX,
|
|
71
72
|
wrap_exceptions: bool = False,
|
|
72
73
|
**options: float | str | bool,
|
|
73
74
|
) -> None:
|
|
@@ -82,6 +83,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
82
83
|
:pypi:`valkey`.
|
|
83
84
|
:param connection_pool: if provided, the redis client is initialized with
|
|
84
85
|
the connection pool and any other params passed as :paramref:`options`
|
|
86
|
+
:param key_prefix: the prefix for each key created in redis
|
|
85
87
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
|
86
88
|
:exc:`limits.errors.StorageError` before raising it.
|
|
87
89
|
:param options: all remaining keyword arguments are passed
|
|
@@ -89,6 +91,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
89
91
|
:raise ConfigurationError: when the :pypi:`redis` library is not available
|
|
90
92
|
"""
|
|
91
93
|
super().__init__(uri, wrap_exceptions=wrap_exceptions, **options)
|
|
94
|
+
self.key_prefix = key_prefix
|
|
92
95
|
self.target_server = "valkey" if uri.startswith("valkey") else "redis"
|
|
93
96
|
self.dependency = self.dependencies[self.target_server].module
|
|
94
97
|
|
|
@@ -165,7 +168,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
165
168
|
return f"{self._current_window_key(key)}/-1"
|
|
166
169
|
|
|
167
170
|
def prefixed_key(self, key: str) -> str:
|
|
168
|
-
return f"{self.
|
|
171
|
+
return f"{self.key_prefix}:{key}"
|
|
169
172
|
|
|
170
173
|
def get_moving_window(self, key: str, limit: int, expiry: int) -> tuple[float, int]:
|
|
171
174
|
"""
|
|
@@ -197,6 +200,12 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
197
200
|
)
|
|
198
201
|
return 0, 0.0, 0, 0.0
|
|
199
202
|
|
|
203
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
204
|
+
previous_key = self._previous_window_key(key)
|
|
205
|
+
current_key = self._current_window_key(key)
|
|
206
|
+
self.clear(previous_key)
|
|
207
|
+
self.clear(current_key)
|
|
208
|
+
|
|
200
209
|
def incr(
|
|
201
210
|
self,
|
|
202
211
|
key: str,
|
|
@@ -295,7 +304,7 @@ class RedisStorage(Storage, MovingWindowSupport, SlidingWindowCounterSupport):
|
|
|
295
304
|
def reset(self) -> int | None:
|
|
296
305
|
"""
|
|
297
306
|
This function calls a Lua Script to delete keys prefixed with
|
|
298
|
-
|
|
307
|
+
:paramref:`RedisStorage.key_prefix` in blocks of 5000.
|
|
299
308
|
|
|
300
309
|
.. warning::
|
|
301
310
|
This operation was designed to be fast, but was not tested
|
|
@@ -56,6 +56,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
56
56
|
def __init__(
|
|
57
57
|
self,
|
|
58
58
|
uri: str,
|
|
59
|
+
key_prefix: str = RedisStorage.PREFIX,
|
|
59
60
|
wrap_exceptions: bool = False,
|
|
60
61
|
**options: float | str | bool,
|
|
61
62
|
) -> None:
|
|
@@ -65,6 +66,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
65
66
|
|
|
66
67
|
If the uri scheme is ``valkey+cluster`` the implementation used will be from
|
|
67
68
|
:pypi:`valkey`.
|
|
69
|
+
:param key_prefix: the prefix for each key created in redis
|
|
68
70
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
|
69
71
|
:exc:`limits.errors.StorageError` before raising it.
|
|
70
72
|
:param options: all remaining keyword arguments are passed
|
|
@@ -86,6 +88,7 @@ class RedisClusterStorage(RedisStorage):
|
|
|
86
88
|
host, port = loc.split(":")
|
|
87
89
|
cluster_hosts.append((host, int(port)))
|
|
88
90
|
|
|
91
|
+
self.key_prefix = key_prefix
|
|
89
92
|
self.storage = None
|
|
90
93
|
self.target_server = "valkey" if uri.startswith("valkey") else "redis"
|
|
91
94
|
merged_options = {**self.DEFAULT_OPTIONS, **parsed_auth, **options}
|
|
@@ -108,8 +111,8 @@ class RedisClusterStorage(RedisStorage):
|
|
|
108
111
|
"""
|
|
109
112
|
Redis Clusters are sharded and deleting across shards
|
|
110
113
|
can't be done atomically. Because of this, this reset loops over all
|
|
111
|
-
keys that are prefixed with
|
|
112
|
-
one at a time.
|
|
114
|
+
keys that are prefixed with :paramref:`RedisClusterStorage.prefix` and
|
|
115
|
+
calls delete on them one at a time.
|
|
113
116
|
|
|
114
117
|
.. warning::
|
|
115
118
|
This operation was not tested with extremely large data sets.
|
|
@@ -45,6 +45,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
45
45
|
service_name: str | None = None,
|
|
46
46
|
use_replicas: bool = True,
|
|
47
47
|
sentinel_kwargs: dict[str, float | str | bool] | None = None,
|
|
48
|
+
key_prefix: str = RedisStorage.PREFIX,
|
|
48
49
|
wrap_exceptions: bool = False,
|
|
49
50
|
**options: float | str | bool,
|
|
50
51
|
) -> None:
|
|
@@ -59,6 +60,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
59
60
|
:param use_replicas: Whether to use replicas for read only operations
|
|
60
61
|
:param sentinel_kwargs: kwargs to pass as
|
|
61
62
|
:attr:`sentinel_kwargs` to :class:`redis.sentinel.Sentinel`
|
|
63
|
+
:param key_prefix: the prefix for each key created in redis
|
|
62
64
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
|
63
65
|
:exc:`limits.errors.StorageError` before raising it.
|
|
64
66
|
:param options: all remaining keyword arguments are passed
|
|
@@ -87,6 +89,7 @@ class RedisSentinelStorage(RedisStorage):
|
|
|
87
89
|
for loc in parsed.netloc[sep:].split(","):
|
|
88
90
|
host, port = loc.split(":")
|
|
89
91
|
sentinel_configuration.append((host, int(port)))
|
|
92
|
+
self.key_prefix = key_prefix
|
|
90
93
|
self.service_name = (
|
|
91
94
|
parsed.path.replace("/", "") if parsed.path else service_name
|
|
92
95
|
)
|
|
@@ -284,6 +284,11 @@ class SlidingWindowCounterRateLimiter(RateLimiter):
|
|
|
284
284
|
|
|
285
285
|
return WindowStats(now + min(previous_reset_in, current_reset_in), remaining)
|
|
286
286
|
|
|
287
|
+
def clear(self, item: RateLimitItem, *identifiers: str) -> None:
|
|
288
|
+
return cast(SlidingWindowCounterSupport, self.storage).clear_sliding_window(
|
|
289
|
+
item.key_for(*identifiers), item.get_expiry()
|
|
290
|
+
)
|
|
291
|
+
|
|
287
292
|
|
|
288
293
|
KnownStrategy = (
|
|
289
294
|
type[SlidingWindowCounterRateLimiter]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: limits
|
|
3
|
-
Version: 5.0
|
|
3
|
+
Version: 5.2.0
|
|
4
4
|
Summary: Rate limiting utilities
|
|
5
5
|
Home-page: https://limits.readthedocs.org
|
|
6
6
|
Author: Ali-Akber Saifee
|
|
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
License-File: LICENSE.txt
|
|
24
24
|
Requires-Dist: deprecated>=1.2
|
|
25
|
-
Requires-Dist: packaging<
|
|
25
|
+
Requires-Dist: packaging<26,>=21
|
|
26
26
|
Requires-Dist: typing_extensions
|
|
27
27
|
Provides-Extra: redis
|
|
28
28
|
Requires-Dist: redis!=4.5.2,!=4.5.3,<6.0.0,>3; extra == "redis"
|
|
@@ -136,6 +136,9 @@ class TestBaseStorage:
|
|
|
136
136
|
) -> tuple[int, float, int, float]:
|
|
137
137
|
pass
|
|
138
138
|
|
|
139
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
140
|
+
pass
|
|
141
|
+
|
|
139
142
|
storage = storage_from_string("mystorage+sliding://")
|
|
140
143
|
assert isinstance(storage, MyStorage)
|
|
141
144
|
SlidingWindowCounterRateLimiter(storage)
|
|
@@ -370,6 +373,9 @@ class TestStorageErrors:
|
|
|
370
373
|
) -> tuple[int, float, int, float]:
|
|
371
374
|
raise self.MyError()
|
|
372
375
|
|
|
376
|
+
def clear_sliding_window(self, key: str, expiry: int) -> None:
|
|
377
|
+
raise self.MyError()
|
|
378
|
+
|
|
373
379
|
def assert_exception(self, exc, wrap_exceptions):
|
|
374
380
|
if wrap_exceptions:
|
|
375
381
|
assert isinstance(exc, StorageError)
|
|
@@ -436,3 +442,9 @@ class TestStorageErrors:
|
|
|
436
442
|
with pytest.raises(Exception) as exc:
|
|
437
443
|
self.MyStorage(wrap_exceptions=wrap_exceptions).get_sliding_window("", 1)
|
|
438
444
|
self.assert_exception(exc.value, wrap_exceptions)
|
|
445
|
+
|
|
446
|
+
def test_clear_sliding_window_exception(self, wrap_exceptions):
|
|
447
|
+
with pytest.raises(Exception) as exc:
|
|
448
|
+
self.MyStorage(wrap_exceptions=wrap_exceptions).clear_sliding_window("", 1)
|
|
449
|
+
|
|
450
|
+
self.assert_exception(exc.value, wrap_exceptions)
|
|
@@ -108,6 +108,9 @@ class TestSlidingWindow:
|
|
|
108
108
|
start + 2, 1e-2
|
|
109
109
|
)
|
|
110
110
|
|
|
111
|
+
limiter.clear(limit)
|
|
112
|
+
assert 10 == limiter.get_window_stats(limit).remaining
|
|
113
|
+
|
|
111
114
|
@pytest.mark.flaky
|
|
112
115
|
def test_sliding_window_counter_total_reset(self, uri, args, fixture):
|
|
113
116
|
storage = storage_from_string(uri, **args)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_moving_window.lua
RENAMED
|
File without changes
|
{limits-5.0.0rc2 → limits-5.2.0}/limits/resources/redis/lua_scripts/acquire_sliding_window.lua
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|