limits 4.1__py3-none-any.whl → 4.3__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/__init__.py +8 -6
- limits/_version.py +4 -4
- limits/aio/__init__.py +2 -0
- limits/aio/storage/__init__.py +6 -4
- limits/aio/storage/base.py +5 -8
- limits/aio/storage/etcd.py +6 -4
- limits/aio/storage/memcached.py +6 -4
- limits/aio/storage/memory.py +42 -26
- limits/aio/storage/mongodb.py +4 -7
- limits/aio/storage/redis/__init__.py +402 -0
- limits/aio/storage/redis/bridge.py +120 -0
- limits/aio/storage/redis/coredis.py +209 -0
- limits/aio/storage/redis/redispy.py +257 -0
- limits/aio/storage/redis/valkey.py +9 -0
- limits/aio/strategies.py +4 -2
- limits/errors.py +2 -0
- limits/storage/__init__.py +14 -11
- limits/storage/base.py +5 -10
- limits/storage/etcd.py +6 -4
- limits/storage/memcached.py +6 -7
- limits/storage/memory.py +42 -31
- limits/storage/mongodb.py +7 -10
- limits/storage/redis.py +48 -18
- limits/storage/redis_cluster.py +31 -11
- limits/storage/redis_sentinel.py +35 -11
- limits/storage/registry.py +1 -3
- limits/strategies.py +11 -9
- limits/typing.py +45 -42
- limits/util.py +12 -12
- {limits-4.1.dist-info → limits-4.3.dist-info}/METADATA +52 -36
- limits-4.3.dist-info/RECORD +43 -0
- {limits-4.1.dist-info → limits-4.3.dist-info}/WHEEL +1 -1
- limits/aio/storage/redis.py +0 -555
- limits-4.1.dist-info/RECORD +0 -39
- {limits-4.1.dist-info → limits-4.3.dist-info}/LICENSE.txt +0 -0
- {limits-4.1.dist-info → limits-4.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
from limits.aio.storage.redis.bridge import RedisBridge
|
|
7
|
+
from limits.errors import ConfigurationError
|
|
8
|
+
from limits.typing import AsyncCoRedisClient, Callable
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import coredis
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CoredisBridge(RedisBridge):
|
|
15
|
+
DEFAULT_CLUSTER_OPTIONS: dict[str, float | str | bool] = {
|
|
16
|
+
"max_connections": 1000,
|
|
17
|
+
}
|
|
18
|
+
"Default options passed to :class:`coredis.RedisCluster`"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
|
22
|
+
return (self.dependency.exceptions.RedisError,)
|
|
23
|
+
|
|
24
|
+
def use_sentinel(
|
|
25
|
+
self,
|
|
26
|
+
service_name: str | None,
|
|
27
|
+
use_replicas: bool,
|
|
28
|
+
sentinel_kwargs: dict[str, str | float | bool] | None,
|
|
29
|
+
**options: str | float | bool,
|
|
30
|
+
) -> None:
|
|
31
|
+
sentinel_configuration = []
|
|
32
|
+
connection_options = options.copy()
|
|
33
|
+
|
|
34
|
+
sep = self.parsed_uri.netloc.find("@") + 1
|
|
35
|
+
|
|
36
|
+
for loc in self.parsed_uri.netloc[sep:].split(","):
|
|
37
|
+
host, port = loc.split(":")
|
|
38
|
+
sentinel_configuration.append((host, int(port)))
|
|
39
|
+
service_name = (
|
|
40
|
+
self.parsed_uri.path.replace("/", "")
|
|
41
|
+
if self.parsed_uri.path
|
|
42
|
+
else service_name
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if service_name is None:
|
|
46
|
+
raise ConfigurationError("'service_name' not provided")
|
|
47
|
+
|
|
48
|
+
self.sentinel = self.dependency.sentinel.Sentinel(
|
|
49
|
+
sentinel_configuration,
|
|
50
|
+
sentinel_kwargs={**self.parsed_auth, **(sentinel_kwargs or {})},
|
|
51
|
+
**{**self.parsed_auth, **connection_options},
|
|
52
|
+
)
|
|
53
|
+
self.storage = self.sentinel.primary_for(service_name)
|
|
54
|
+
self.storage_replica = self.sentinel.replica_for(service_name)
|
|
55
|
+
self.connection_getter = lambda readonly: (
|
|
56
|
+
self.storage_replica if readonly and use_replicas else self.storage
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def use_basic(self, **options: str | float | bool) -> None:
|
|
60
|
+
if connection_pool := options.pop("connection_pool", None):
|
|
61
|
+
self.storage = self.dependency.Redis(
|
|
62
|
+
connection_pool=connection_pool, **options
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
self.storage = self.dependency.Redis.from_url(self.uri, **options)
|
|
66
|
+
|
|
67
|
+
self.connection_getter = lambda _: self.storage
|
|
68
|
+
|
|
69
|
+
def use_cluster(self, **options: str | float | bool) -> None:
|
|
70
|
+
sep = self.parsed_uri.netloc.find("@") + 1
|
|
71
|
+
cluster_hosts: list[dict[str, int | str]] = []
|
|
72
|
+
cluster_hosts.extend(
|
|
73
|
+
{"host": host, "port": int(port)}
|
|
74
|
+
for loc in self.parsed_uri.netloc[sep:].split(",")
|
|
75
|
+
if loc
|
|
76
|
+
for host, port in [loc.split(":")]
|
|
77
|
+
)
|
|
78
|
+
self.storage = self.dependency.RedisCluster(
|
|
79
|
+
startup_nodes=cluster_hosts,
|
|
80
|
+
**{**self.DEFAULT_CLUSTER_OPTIONS, **self.parsed_auth, **options},
|
|
81
|
+
)
|
|
82
|
+
self.connection_getter = lambda _: self.storage
|
|
83
|
+
|
|
84
|
+
lua_moving_window: coredis.commands.Script[bytes]
|
|
85
|
+
lua_acquire_moving_window: coredis.commands.Script[bytes]
|
|
86
|
+
lua_sliding_window: coredis.commands.Script[bytes]
|
|
87
|
+
lua_acquire_sliding_window: coredis.commands.Script[bytes]
|
|
88
|
+
lua_clear_keys: coredis.commands.Script[bytes]
|
|
89
|
+
lua_incr_expire: coredis.commands.Script[bytes]
|
|
90
|
+
connection_getter: Callable[[bool], AsyncCoRedisClient]
|
|
91
|
+
|
|
92
|
+
def get_connection(self, readonly: bool = False) -> AsyncCoRedisClient:
|
|
93
|
+
return self.connection_getter(readonly)
|
|
94
|
+
|
|
95
|
+
def register_scripts(self) -> None:
|
|
96
|
+
self.lua_moving_window = self.get_connection().register_script(
|
|
97
|
+
self.SCRIPT_MOVING_WINDOW
|
|
98
|
+
)
|
|
99
|
+
self.lua_acquire_moving_window = self.get_connection().register_script(
|
|
100
|
+
self.SCRIPT_ACQUIRE_MOVING_WINDOW
|
|
101
|
+
)
|
|
102
|
+
self.lua_clear_keys = self.get_connection().register_script(
|
|
103
|
+
self.SCRIPT_CLEAR_KEYS
|
|
104
|
+
)
|
|
105
|
+
self.lua_incr_expire = self.get_connection().register_script(
|
|
106
|
+
self.SCRIPT_INCR_EXPIRE
|
|
107
|
+
)
|
|
108
|
+
self.lua_sliding_window = self.get_connection().register_script(
|
|
109
|
+
self.SCRIPT_SLIDING_WINDOW
|
|
110
|
+
)
|
|
111
|
+
self.lua_acquire_sliding_window = self.get_connection().register_script(
|
|
112
|
+
self.SCRIPT_ACQUIRE_SLIDING_WINDOW
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def incr(
|
|
116
|
+
self, key: str, expiry: int, elastic_expiry: bool = False, amount: int = 1
|
|
117
|
+
) -> int:
|
|
118
|
+
key = self.prefixed_key(key)
|
|
119
|
+
value = await self.get_connection().incrby(key, amount)
|
|
120
|
+
if elastic_expiry or value == amount:
|
|
121
|
+
await self.get_connection().expire(key, expiry)
|
|
122
|
+
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
async def get(self, key: str) -> int:
|
|
126
|
+
key = self.prefixed_key(key)
|
|
127
|
+
return int(await self.get_connection(readonly=True).get(key) or 0)
|
|
128
|
+
|
|
129
|
+
async def clear(self, key: str) -> None:
|
|
130
|
+
key = self.prefixed_key(key)
|
|
131
|
+
await self.get_connection().delete([key])
|
|
132
|
+
|
|
133
|
+
async def lua_reset(self) -> int | None:
|
|
134
|
+
return cast(int, await self.lua_clear_keys.execute([self.prefixed_key("*")]))
|
|
135
|
+
|
|
136
|
+
async def get_moving_window(
|
|
137
|
+
self, key: str, limit: int, expiry: int
|
|
138
|
+
) -> tuple[float, int]:
|
|
139
|
+
key = self.prefixed_key(key)
|
|
140
|
+
timestamp = time.time()
|
|
141
|
+
window = await self.lua_moving_window.execute(
|
|
142
|
+
[key], [timestamp - expiry, limit]
|
|
143
|
+
)
|
|
144
|
+
if window:
|
|
145
|
+
return float(window[0]), window[1] # type: ignore
|
|
146
|
+
return timestamp, 0
|
|
147
|
+
|
|
148
|
+
async def get_sliding_window(
|
|
149
|
+
self, previous_key: str, current_key: str, expiry: int
|
|
150
|
+
) -> tuple[int, float, int, float]:
|
|
151
|
+
previous_key = self.prefixed_key(previous_key)
|
|
152
|
+
current_key = self.prefixed_key(current_key)
|
|
153
|
+
|
|
154
|
+
if window := await self.lua_sliding_window.execute(
|
|
155
|
+
[previous_key, current_key], [expiry]
|
|
156
|
+
):
|
|
157
|
+
return (
|
|
158
|
+
int(window[0] or 0), # type: ignore
|
|
159
|
+
max(0, float(window[1] or 0)) / 1000, # type: ignore
|
|
160
|
+
int(window[2] or 0), # type: ignore
|
|
161
|
+
max(0, float(window[3] or 0)) / 1000, # type: ignore
|
|
162
|
+
)
|
|
163
|
+
return 0, 0.0, 0, 0.0
|
|
164
|
+
|
|
165
|
+
async def acquire_entry(
|
|
166
|
+
self, key: str, limit: int, expiry: int, amount: int = 1
|
|
167
|
+
) -> bool:
|
|
168
|
+
key = self.prefixed_key(key)
|
|
169
|
+
timestamp = time.time()
|
|
170
|
+
acquired = await self.lua_acquire_moving_window.execute(
|
|
171
|
+
[key], [timestamp, limit, expiry, amount]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return bool(acquired)
|
|
175
|
+
|
|
176
|
+
async def acquire_sliding_window_entry(
|
|
177
|
+
self,
|
|
178
|
+
previous_key: str,
|
|
179
|
+
current_key: str,
|
|
180
|
+
limit: int,
|
|
181
|
+
expiry: int,
|
|
182
|
+
amount: int = 1,
|
|
183
|
+
) -> bool:
|
|
184
|
+
previous_key = self.prefixed_key(previous_key)
|
|
185
|
+
current_key = self.prefixed_key(current_key)
|
|
186
|
+
acquired = await self.lua_acquire_sliding_window.execute(
|
|
187
|
+
[previous_key, current_key], [limit, expiry, amount]
|
|
188
|
+
)
|
|
189
|
+
return bool(acquired)
|
|
190
|
+
|
|
191
|
+
async def get_expiry(self, key: str) -> float:
|
|
192
|
+
key = self.prefixed_key(key)
|
|
193
|
+
return max(await self.get_connection().ttl(key), 0) + time.time()
|
|
194
|
+
|
|
195
|
+
async def check(self) -> bool:
|
|
196
|
+
try:
|
|
197
|
+
await self.get_connection().ping()
|
|
198
|
+
|
|
199
|
+
return True
|
|
200
|
+
except: # noqa
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
async def reset(self) -> int | None:
|
|
204
|
+
prefix = self.prefixed_key("*")
|
|
205
|
+
keys = await self.storage.keys(prefix)
|
|
206
|
+
count = 0
|
|
207
|
+
for key in keys:
|
|
208
|
+
count += await self.storage.delete([key])
|
|
209
|
+
return count
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
|
+
|
|
6
|
+
from limits.aio.storage.redis.bridge import RedisBridge
|
|
7
|
+
from limits.errors import ConfigurationError
|
|
8
|
+
from limits.typing import AsyncRedisClient, Callable
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import redis.commands
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RedispyBridge(RedisBridge):
|
|
15
|
+
DEFAULT_CLUSTER_OPTIONS: dict[str, float | str | bool] = {
|
|
16
|
+
"max_connections": 1000,
|
|
17
|
+
}
|
|
18
|
+
"Default options passed to :class:`redis.asyncio.RedisCluster`"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
|
22
|
+
return (self.dependency.RedisError,)
|
|
23
|
+
|
|
24
|
+
def use_sentinel(
|
|
25
|
+
self,
|
|
26
|
+
service_name: str | None,
|
|
27
|
+
use_replicas: bool,
|
|
28
|
+
sentinel_kwargs: dict[str, str | float | bool] | None,
|
|
29
|
+
**options: str | float | bool,
|
|
30
|
+
) -> None:
|
|
31
|
+
sentinel_configuration = []
|
|
32
|
+
|
|
33
|
+
connection_options = options.copy()
|
|
34
|
+
|
|
35
|
+
sep = self.parsed_uri.netloc.find("@") + 1
|
|
36
|
+
|
|
37
|
+
for loc in self.parsed_uri.netloc[sep:].split(","):
|
|
38
|
+
host, port = loc.split(":")
|
|
39
|
+
sentinel_configuration.append((host, int(port)))
|
|
40
|
+
service_name = (
|
|
41
|
+
self.parsed_uri.path.replace("/", "")
|
|
42
|
+
if self.parsed_uri.path
|
|
43
|
+
else service_name
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if service_name is None:
|
|
47
|
+
raise ConfigurationError("'service_name' not provided")
|
|
48
|
+
|
|
49
|
+
self.sentinel = self.dependency.asyncio.Sentinel(
|
|
50
|
+
sentinel_configuration,
|
|
51
|
+
sentinel_kwargs={**self.parsed_auth, **(sentinel_kwargs or {})},
|
|
52
|
+
**{**self.parsed_auth, **connection_options},
|
|
53
|
+
)
|
|
54
|
+
self.storage = self.sentinel.master_for(service_name)
|
|
55
|
+
self.storage_replica = self.sentinel.slave_for(service_name)
|
|
56
|
+
self.connection_getter = lambda readonly: (
|
|
57
|
+
self.storage_replica if readonly and use_replicas else self.storage
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def use_basic(self, **options: str | float | bool) -> None:
|
|
61
|
+
if connection_pool := options.pop("connection_pool", None):
|
|
62
|
+
self.storage = self.dependency.asyncio.Redis(
|
|
63
|
+
connection_pool=connection_pool, **options
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
self.storage = self.dependency.asyncio.Redis.from_url(self.uri, **options)
|
|
67
|
+
|
|
68
|
+
self.connection_getter = lambda _: self.storage
|
|
69
|
+
|
|
70
|
+
def use_cluster(self, **options: str | float | bool) -> None:
|
|
71
|
+
sep = self.parsed_uri.netloc.find("@") + 1
|
|
72
|
+
cluster_hosts = []
|
|
73
|
+
|
|
74
|
+
for loc in self.parsed_uri.netloc[sep:].split(","):
|
|
75
|
+
host, port = loc.split(":")
|
|
76
|
+
cluster_hosts.append(
|
|
77
|
+
self.dependency.asyncio.cluster.ClusterNode(host=host, port=int(port))
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
self.storage = self.dependency.asyncio.RedisCluster(
|
|
81
|
+
startup_nodes=cluster_hosts,
|
|
82
|
+
**{**self.DEFAULT_CLUSTER_OPTIONS, **self.parsed_auth, **options},
|
|
83
|
+
)
|
|
84
|
+
self.connection_getter = lambda _: self.storage
|
|
85
|
+
|
|
86
|
+
lua_moving_window: redis.commands.core.Script
|
|
87
|
+
lua_acquire_moving_window: redis.commands.core.Script
|
|
88
|
+
lua_sliding_window: redis.commands.core.Script
|
|
89
|
+
lua_acquire_sliding_window: redis.commands.core.Script
|
|
90
|
+
lua_clear_keys: redis.commands.core.Script
|
|
91
|
+
lua_incr_expire: redis.commands.core.Script
|
|
92
|
+
connection_getter: Callable[[bool], AsyncRedisClient]
|
|
93
|
+
|
|
94
|
+
def get_connection(self, readonly: bool = False) -> AsyncRedisClient:
|
|
95
|
+
return self.connection_getter(readonly)
|
|
96
|
+
|
|
97
|
+
def register_scripts(self) -> None:
|
|
98
|
+
# Redis-py uses a slightly different script registration
|
|
99
|
+
self.lua_moving_window = self.get_connection().register_script(
|
|
100
|
+
self.SCRIPT_MOVING_WINDOW
|
|
101
|
+
)
|
|
102
|
+
self.lua_acquire_moving_window = self.get_connection().register_script(
|
|
103
|
+
self.SCRIPT_ACQUIRE_MOVING_WINDOW
|
|
104
|
+
)
|
|
105
|
+
self.lua_clear_keys = self.get_connection().register_script(
|
|
106
|
+
self.SCRIPT_CLEAR_KEYS
|
|
107
|
+
)
|
|
108
|
+
self.lua_incr_expire = self.get_connection().register_script(
|
|
109
|
+
self.SCRIPT_INCR_EXPIRE
|
|
110
|
+
)
|
|
111
|
+
self.lua_sliding_window = self.get_connection().register_script(
|
|
112
|
+
self.SCRIPT_SLIDING_WINDOW
|
|
113
|
+
)
|
|
114
|
+
self.lua_acquire_sliding_window = self.get_connection().register_script(
|
|
115
|
+
self.SCRIPT_ACQUIRE_SLIDING_WINDOW
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def incr(
|
|
119
|
+
self,
|
|
120
|
+
key: str,
|
|
121
|
+
expiry: int,
|
|
122
|
+
elastic_expiry: bool = False,
|
|
123
|
+
amount: int = 1,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""
|
|
126
|
+
increments the counter for a given rate limit key
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
:param key: the key to increment
|
|
130
|
+
:param expiry: amount in seconds for the key to expire in
|
|
131
|
+
:param amount: the number to increment by
|
|
132
|
+
"""
|
|
133
|
+
key = self.prefixed_key(key)
|
|
134
|
+
|
|
135
|
+
if elastic_expiry:
|
|
136
|
+
value = await self.get_connection().incrby(key, amount)
|
|
137
|
+
await self.get_connection().expire(key, expiry)
|
|
138
|
+
return value
|
|
139
|
+
else:
|
|
140
|
+
return cast(int, await self.lua_incr_expire([key], [expiry, amount]))
|
|
141
|
+
|
|
142
|
+
async def get(self, key: str) -> int:
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
:param key: the key to get the counter value for
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
key = self.prefixed_key(key)
|
|
149
|
+
return int(await self.get_connection(readonly=True).get(key) or 0)
|
|
150
|
+
|
|
151
|
+
async def clear(self, key: str) -> None:
|
|
152
|
+
"""
|
|
153
|
+
:param key: the key to clear rate limits for
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
key = self.prefixed_key(key)
|
|
157
|
+
await self.get_connection().delete(key)
|
|
158
|
+
|
|
159
|
+
async def lua_reset(self) -> int | None:
|
|
160
|
+
return cast(int, await self.lua_clear_keys([self.prefixed_key("*")]))
|
|
161
|
+
|
|
162
|
+
async def get_moving_window(
|
|
163
|
+
self, key: str, limit: int, expiry: int
|
|
164
|
+
) -> tuple[float, int]:
|
|
165
|
+
"""
|
|
166
|
+
returns the starting point and the number of entries in the moving
|
|
167
|
+
window
|
|
168
|
+
|
|
169
|
+
:param key: rate limit key
|
|
170
|
+
:param expiry: expiry of entry
|
|
171
|
+
:return: (previous count, previous TTL, current count, current TTL)
|
|
172
|
+
"""
|
|
173
|
+
key = self.prefixed_key(key)
|
|
174
|
+
timestamp = time.time()
|
|
175
|
+
window = await self.lua_moving_window([key], [timestamp - expiry, limit])
|
|
176
|
+
if window:
|
|
177
|
+
return float(window[0]), window[1]
|
|
178
|
+
return timestamp, 0
|
|
179
|
+
|
|
180
|
+
async def get_sliding_window(
|
|
181
|
+
self, previous_key: str, current_key: str, expiry: int
|
|
182
|
+
) -> tuple[int, float, int, float]:
|
|
183
|
+
if window := await self.lua_sliding_window(
|
|
184
|
+
[self.prefixed_key(previous_key), self.prefixed_key(current_key)], [expiry]
|
|
185
|
+
):
|
|
186
|
+
return (
|
|
187
|
+
int(window[0] or 0),
|
|
188
|
+
max(0, float(window[1] or 0)) / 1000,
|
|
189
|
+
int(window[2] or 0),
|
|
190
|
+
max(0, float(window[3] or 0)) / 1000,
|
|
191
|
+
)
|
|
192
|
+
return 0, 0.0, 0, 0.0
|
|
193
|
+
|
|
194
|
+
async def acquire_entry(
|
|
195
|
+
self,
|
|
196
|
+
key: str,
|
|
197
|
+
limit: int,
|
|
198
|
+
expiry: int,
|
|
199
|
+
amount: int = 1,
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""
|
|
202
|
+
:param key: rate limit key to acquire an entry in
|
|
203
|
+
:param limit: amount of entries allowed
|
|
204
|
+
:param expiry: expiry of the entry
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
key = self.prefixed_key(key)
|
|
208
|
+
timestamp = time.time()
|
|
209
|
+
acquired = await self.lua_acquire_moving_window(
|
|
210
|
+
[key], [timestamp, limit, expiry, amount]
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return bool(acquired)
|
|
214
|
+
|
|
215
|
+
async def acquire_sliding_window_entry(
|
|
216
|
+
self,
|
|
217
|
+
previous_key: str,
|
|
218
|
+
current_key: str,
|
|
219
|
+
limit: int,
|
|
220
|
+
expiry: int,
|
|
221
|
+
amount: int = 1,
|
|
222
|
+
) -> bool:
|
|
223
|
+
previous_key = self.prefixed_key(previous_key)
|
|
224
|
+
current_key = self.prefixed_key(current_key)
|
|
225
|
+
acquired = await self.lua_acquire_sliding_window(
|
|
226
|
+
[previous_key, current_key], [limit, expiry, amount]
|
|
227
|
+
)
|
|
228
|
+
return bool(acquired)
|
|
229
|
+
|
|
230
|
+
async def get_expiry(self, key: str) -> float:
|
|
231
|
+
"""
|
|
232
|
+
:param key: the key to get the expiry for
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
key = self.prefixed_key(key)
|
|
236
|
+
return max(await self.get_connection().ttl(key), 0) + time.time()
|
|
237
|
+
|
|
238
|
+
async def check(self) -> bool:
|
|
239
|
+
"""
|
|
240
|
+
check if storage is healthy
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
await self.get_connection().ping()
|
|
244
|
+
|
|
245
|
+
return True
|
|
246
|
+
except: # noqa
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
async def reset(self) -> int | None:
|
|
250
|
+
prefix = self.prefixed_key("*")
|
|
251
|
+
keys = await self.storage.keys(
|
|
252
|
+
prefix, target_nodes=self.dependency.asyncio.cluster.RedisCluster.ALL_NODES
|
|
253
|
+
)
|
|
254
|
+
count = 0
|
|
255
|
+
for key in keys:
|
|
256
|
+
count += await self.storage.delete(key)
|
|
257
|
+
return count
|
limits/aio/strategies.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Asynchronous rate limiting strategies
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import time
|
|
6
8
|
from abc import ABC, abstractmethod
|
|
7
9
|
from math import floor, inf
|
|
@@ -74,7 +76,7 @@ class MovingWindowRateLimiter(RateLimiter):
|
|
|
74
76
|
):
|
|
75
77
|
raise NotImplementedError(
|
|
76
78
|
"MovingWindowRateLimiting is not implemented for storage "
|
|
77
|
-
"of type
|
|
79
|
+
f"of type {storage.__class__}"
|
|
78
80
|
)
|
|
79
81
|
super().__init__(storage)
|
|
80
82
|
|
|
@@ -200,7 +202,7 @@ class SlidingWindowCounterRateLimiter(RateLimiter):
|
|
|
200
202
|
):
|
|
201
203
|
raise NotImplementedError(
|
|
202
204
|
"SlidingWindowCounterRateLimiting is not implemented for storage "
|
|
203
|
-
"of type
|
|
205
|
+
f"of type {storage.__class__}"
|
|
204
206
|
)
|
|
205
207
|
super().__init__(storage)
|
|
206
208
|
|
limits/errors.py
CHANGED
limits/storage/__init__.py
CHANGED
|
@@ -3,12 +3,14 @@ Implementations of storage backends to be used with
|
|
|
3
3
|
:class:`limits.strategies.RateLimiter` strategies
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
6
8
|
import urllib
|
|
7
9
|
|
|
8
10
|
import limits # noqa
|
|
9
11
|
|
|
10
12
|
from ..errors import ConfigurationError
|
|
11
|
-
from ..typing import
|
|
13
|
+
from ..typing import TypeAlias, cast
|
|
12
14
|
from .base import MovingWindowSupport, SlidingWindowCounterSupport, Storage
|
|
13
15
|
from .etcd import EtcdStorage
|
|
14
16
|
from .memcached import MemcachedStorage
|
|
@@ -19,11 +21,11 @@ from .redis_cluster import RedisClusterStorage
|
|
|
19
21
|
from .redis_sentinel import RedisSentinelStorage
|
|
20
22
|
from .registry import SCHEMES
|
|
21
23
|
|
|
22
|
-
StorageTypes =
|
|
24
|
+
StorageTypes: TypeAlias = "Storage | limits.aio.storage.Storage"
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def storage_from_string(
|
|
26
|
-
storage_string: str, **options:
|
|
28
|
+
storage_string: str, **options: float | str | bool
|
|
27
29
|
) -> StorageTypes:
|
|
28
30
|
"""
|
|
29
31
|
Factory function to get an instance of the storage class based
|
|
@@ -59,21 +61,22 @@ def storage_from_string(
|
|
|
59
61
|
scheme = urllib.parse.urlparse(storage_string).scheme
|
|
60
62
|
|
|
61
63
|
if scheme not in SCHEMES:
|
|
62
|
-
raise ConfigurationError("unknown storage scheme :
|
|
64
|
+
raise ConfigurationError(f"unknown storage scheme : {storage_string}")
|
|
65
|
+
|
|
63
66
|
return cast(StorageTypes, SCHEMES[scheme](storage_string, **options))
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
__all__ = [
|
|
67
|
-
"storage_from_string",
|
|
68
|
-
"Storage",
|
|
69
|
-
"MovingWindowSupport",
|
|
70
|
-
"SlidingWindowCounterSupport",
|
|
71
70
|
"EtcdStorage",
|
|
72
|
-
"
|
|
71
|
+
"MemcachedStorage",
|
|
73
72
|
"MemoryStorage",
|
|
74
73
|
"MongoDBStorage",
|
|
75
|
-
"
|
|
74
|
+
"MongoDBStorageBase",
|
|
75
|
+
"MovingWindowSupport",
|
|
76
76
|
"RedisClusterStorage",
|
|
77
77
|
"RedisSentinelStorage",
|
|
78
|
-
"
|
|
78
|
+
"RedisStorage",
|
|
79
|
+
"SlidingWindowCounterSupport",
|
|
80
|
+
"Storage",
|
|
81
|
+
"storage_from_string",
|
|
79
82
|
]
|
limits/storage/base.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
|
-
import threading
|
|
5
4
|
from abc import ABC, abstractmethod
|
|
6
5
|
|
|
7
6
|
from limits import errors
|
|
@@ -9,11 +8,8 @@ from limits.storage.registry import StorageRegistry
|
|
|
9
8
|
from limits.typing import (
|
|
10
9
|
Any,
|
|
11
10
|
Callable,
|
|
12
|
-
Optional,
|
|
13
11
|
P,
|
|
14
12
|
R,
|
|
15
|
-
Type,
|
|
16
|
-
Union,
|
|
17
13
|
cast,
|
|
18
14
|
)
|
|
19
15
|
from limits.util import LazyDependency
|
|
@@ -40,7 +36,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
|
|
|
40
36
|
Base class to extend when implementing a storage backend.
|
|
41
37
|
"""
|
|
42
38
|
|
|
43
|
-
STORAGE_SCHEME:
|
|
39
|
+
STORAGE_SCHEME: list[str] | None
|
|
44
40
|
"""The storage schemes to register against this implementation"""
|
|
45
41
|
|
|
46
42
|
def __init_subclass__(cls, **kwargs: Any) -> None: # type: ignore[explicit-any]
|
|
@@ -57,22 +53,21 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
|
|
|
57
53
|
|
|
58
54
|
def __init__(
|
|
59
55
|
self,
|
|
60
|
-
uri:
|
|
56
|
+
uri: str | None = None,
|
|
61
57
|
wrap_exceptions: bool = False,
|
|
62
|
-
**options:
|
|
58
|
+
**options: float | str | bool,
|
|
63
59
|
):
|
|
64
60
|
"""
|
|
65
61
|
:param wrap_exceptions: Whether to wrap storage exceptions in
|
|
66
62
|
:exc:`limits.errors.StorageError` before raising it.
|
|
67
63
|
"""
|
|
68
64
|
|
|
69
|
-
self.lock = threading.RLock()
|
|
70
65
|
super().__init__()
|
|
71
66
|
self.wrap_exceptions = wrap_exceptions
|
|
72
67
|
|
|
73
68
|
@property
|
|
74
69
|
@abstractmethod
|
|
75
|
-
def base_exceptions(self) ->
|
|
70
|
+
def base_exceptions(self) -> type[Exception] | tuple[type[Exception], ...]:
|
|
76
71
|
raise NotImplementedError
|
|
77
72
|
|
|
78
73
|
@abstractmethod
|
|
@@ -112,7 +107,7 @@ class Storage(LazyDependency, metaclass=StorageRegistry):
|
|
|
112
107
|
raise NotImplementedError
|
|
113
108
|
|
|
114
109
|
@abstractmethod
|
|
115
|
-
def reset(self) ->
|
|
110
|
+
def reset(self) -> int | None:
|
|
116
111
|
"""
|
|
117
112
|
reset storage to clear limits
|
|
118
113
|
"""
|
limits/storage/etcd.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import time
|
|
2
4
|
import urllib.parse
|
|
3
5
|
|
|
4
6
|
from limits.errors import ConcurrentUpdateError
|
|
5
7
|
from limits.storage.base import Storage
|
|
6
|
-
from limits.typing import TYPE_CHECKING
|
|
8
|
+
from limits.typing import TYPE_CHECKING
|
|
7
9
|
|
|
8
10
|
if TYPE_CHECKING:
|
|
9
11
|
import etcd3
|
|
@@ -42,7 +44,7 @@ class EtcdStorage(Storage):
|
|
|
42
44
|
"""
|
|
43
45
|
parsed = urllib.parse.urlparse(uri)
|
|
44
46
|
self.lib = self.dependencies["etcd3"].module
|
|
45
|
-
self.storage:
|
|
47
|
+
self.storage: etcd3.Etcd3Client = self.lib.client(
|
|
46
48
|
parsed.hostname, parsed.port, **options
|
|
47
49
|
)
|
|
48
50
|
self.max_retries = max_retries
|
|
@@ -51,7 +53,7 @@ class EtcdStorage(Storage):
|
|
|
51
53
|
@property
|
|
52
54
|
def base_exceptions(
|
|
53
55
|
self,
|
|
54
|
-
) ->
|
|
56
|
+
) -> type[Exception] | tuple[type[Exception], ...]: # pragma: no cover
|
|
55
57
|
return self.lib.Etcd3Exception # type: ignore[no-any-return]
|
|
56
58
|
|
|
57
59
|
def prefixed_key(self, key: str) -> bytes:
|
|
@@ -127,7 +129,7 @@ class EtcdStorage(Storage):
|
|
|
127
129
|
except: # noqa
|
|
128
130
|
return False
|
|
129
131
|
|
|
130
|
-
def reset(self) ->
|
|
132
|
+
def reset(self) -> int | None:
|
|
131
133
|
return self.storage.delete_prefix(f"{self.PREFIX}/").deleted
|
|
132
134
|
|
|
133
135
|
def clear(self, key: str) -> None:
|