taplimit 0.1.0__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.
- taplimit/__init__.py +11 -0
- taplimit/_decorator.py +44 -0
- taplimit/_errors.py +15 -0
- taplimit/_limiter.py +103 -0
- taplimit/py.typed +0 -0
- taplimit-0.1.0.dist-info/METADATA +141 -0
- taplimit-0.1.0.dist-info/RECORD +9 -0
- taplimit-0.1.0.dist-info/WHEEL +4 -0
- taplimit-0.1.0.dist-info/licenses/LICENSE +21 -0
taplimit/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Small, typed, thread-safe in-process rate-limit decorator: N calls per period."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._decorator import rate_limit
|
|
6
|
+
from ._errors import RateLimitExceeded
|
|
7
|
+
from ._limiter import RateLimiter
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["RateLimitExceeded", "RateLimiter", "rate_limit"]
|
taplimit/_decorator.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The ``rate_limit`` decorator: the small, obvious entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from typing import ParamSpec, TypeVar
|
|
9
|
+
|
|
10
|
+
from ._limiter import RateLimiter
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
R = TypeVar("R")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rate_limit(
|
|
17
|
+
calls: int,
|
|
18
|
+
period: float,
|
|
19
|
+
*,
|
|
20
|
+
on_limit: str = "block",
|
|
21
|
+
clock: Callable[[], float] = time.monotonic,
|
|
22
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
23
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
24
|
+
"""Limit the decorated function to ``calls`` invocations per ``period`` seconds.
|
|
25
|
+
|
|
26
|
+
With ``on_limit="block"`` (the default) a call over the limit waits until a slot
|
|
27
|
+
frees; with ``on_limit="raise"`` it raises RateLimitExceeded instead. All calls
|
|
28
|
+
to the decorated function share one sliding window. Inject ``clock`` and ``sleep``
|
|
29
|
+
to drive it from tests without real time passing.
|
|
30
|
+
"""
|
|
31
|
+
if on_limit not in ("block", "raise"):
|
|
32
|
+
raise ValueError('on_limit must be "block" or "raise"')
|
|
33
|
+
blocking = on_limit == "block"
|
|
34
|
+
limiter = RateLimiter(calls, period, clock=clock, sleep=sleep)
|
|
35
|
+
|
|
36
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
37
|
+
@wraps(func)
|
|
38
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
39
|
+
limiter.acquire(blocking=blocking)
|
|
40
|
+
return func(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
return decorator
|
taplimit/_errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The error raised when a call would exceed the configured rate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RateLimitExceeded(Exception):
|
|
7
|
+
"""Raised when a call would exceed the rate and blocking is turned off.
|
|
8
|
+
|
|
9
|
+
The ``retry_after`` attribute holds the seconds until the next slot frees,
|
|
10
|
+
so a caller can decide how long to wait before trying again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, retry_after: float) -> None:
|
|
14
|
+
self.retry_after = retry_after
|
|
15
|
+
super().__init__(f"rate limit exceeded, retry after {retry_after:.3f}s")
|
taplimit/_limiter.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""A thread-safe, in-process, sliding-window rate limiter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import ParamSpec, TypeVar
|
|
12
|
+
|
|
13
|
+
from ._errors import RateLimitExceeded
|
|
14
|
+
|
|
15
|
+
P = ParamSpec("P")
|
|
16
|
+
R = TypeVar("R")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimiter:
|
|
20
|
+
"""Allow at most ``calls`` acquisitions per ``period`` seconds.
|
|
21
|
+
|
|
22
|
+
The window slides: a slot frees exactly ``period`` seconds after the call that
|
|
23
|
+
took it, not on a shared clock boundary, so a burst never carries over. The
|
|
24
|
+
limiter is safe to share across threads and across functions, which lets several
|
|
25
|
+
callables draw on a single budget.
|
|
26
|
+
|
|
27
|
+
Inject ``clock`` (returns seconds) and ``sleep`` (waits seconds) to drive the
|
|
28
|
+
limiter from tests without real time passing.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
calls: int,
|
|
34
|
+
period: float,
|
|
35
|
+
*,
|
|
36
|
+
clock: Callable[[], float] = time.monotonic,
|
|
37
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
38
|
+
) -> None:
|
|
39
|
+
if calls < 1:
|
|
40
|
+
raise ValueError("calls must be at least 1")
|
|
41
|
+
if period <= 0:
|
|
42
|
+
raise ValueError("period must be greater than 0")
|
|
43
|
+
self._calls = calls
|
|
44
|
+
self._period = period
|
|
45
|
+
self._clock = clock
|
|
46
|
+
self._sleep = sleep
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._hits: deque[float] = deque()
|
|
49
|
+
|
|
50
|
+
def _purge(self, now: float) -> None:
|
|
51
|
+
horizon = now - self._period
|
|
52
|
+
hits = self._hits
|
|
53
|
+
while hits and hits[0] <= horizon:
|
|
54
|
+
hits.popleft()
|
|
55
|
+
|
|
56
|
+
def try_acquire(self) -> bool:
|
|
57
|
+
"""Take a slot without waiting. Return True if one was free, else False."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
now = self._clock()
|
|
60
|
+
self._purge(now)
|
|
61
|
+
if len(self._hits) < self._calls:
|
|
62
|
+
self._hits.append(now)
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def acquire(self, *, blocking: bool = True) -> None:
|
|
67
|
+
"""Take a slot, waiting for one when ``blocking`` is True.
|
|
68
|
+
|
|
69
|
+
When ``blocking`` is False and every slot is taken, raise RateLimitExceeded
|
|
70
|
+
carrying the seconds until the next slot opens. A rejected call never
|
|
71
|
+
consumes a slot.
|
|
72
|
+
"""
|
|
73
|
+
while True:
|
|
74
|
+
with self._lock:
|
|
75
|
+
now = self._clock()
|
|
76
|
+
self._purge(now)
|
|
77
|
+
if len(self._hits) < self._calls:
|
|
78
|
+
self._hits.append(now)
|
|
79
|
+
return
|
|
80
|
+
wait = self._hits[0] + self._period - now
|
|
81
|
+
if not blocking:
|
|
82
|
+
raise RateLimitExceeded(wait)
|
|
83
|
+
self._sleep(wait if wait > 0 else 0.0)
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> RateLimiter:
|
|
86
|
+
self.acquire()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(
|
|
90
|
+
self,
|
|
91
|
+
exc_type: type[BaseException] | None,
|
|
92
|
+
exc: BaseException | None,
|
|
93
|
+
tb: TracebackType | None,
|
|
94
|
+
) -> None:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
98
|
+
@wraps(func)
|
|
99
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
100
|
+
self.acquire()
|
|
101
|
+
return func(*args, **kwargs)
|
|
102
|
+
|
|
103
|
+
return wrapper
|
taplimit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: taplimit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/taplimit
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/taplimit/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/taplimit/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: decorator,rate-limit,ratelimit,sliding-window,thread-safe,throttle
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# taplimit
|
|
29
|
+
|
|
30
|
+
Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise.
|
|
31
|
+
|
|
32
|
+
Zero dependencies, one obvious decorator, and a reusable limiter object when you need to
|
|
33
|
+
share a budget. The clock and sleep are injectable, so tests never wait on real time.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install taplimit
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
Cap a function at N calls per period. By default a call over the limit blocks until a
|
|
44
|
+
slot frees:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from taplimit import rate_limit
|
|
48
|
+
|
|
49
|
+
@rate_limit(calls=15, period=60)
|
|
50
|
+
def call_api() -> None:
|
|
51
|
+
...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Prefer to fail fast instead of waiting? Ask it to raise:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from taplimit import rate_limit, RateLimitExceeded
|
|
58
|
+
|
|
59
|
+
@rate_limit(calls=15, period=60, on_limit="raise")
|
|
60
|
+
def call_api() -> None:
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
call_api()
|
|
65
|
+
except RateLimitExceeded as exc:
|
|
66
|
+
print(f"slow down, retry in {exc.retry_after:.1f}s")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Share one budget across several functions, or control slots by hand, with the limiter
|
|
70
|
+
object. It is a decorator, a context manager, and exposes `acquire` / `try_acquire`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from taplimit import RateLimiter
|
|
74
|
+
|
|
75
|
+
limiter = RateLimiter(calls=100, period=60)
|
|
76
|
+
|
|
77
|
+
@limiter
|
|
78
|
+
def read() -> None: ...
|
|
79
|
+
|
|
80
|
+
@limiter
|
|
81
|
+
def write() -> None: ... # read and write draw on the same 100-per-minute budget
|
|
82
|
+
|
|
83
|
+
with limiter: # guard a block of code
|
|
84
|
+
do_work()
|
|
85
|
+
|
|
86
|
+
if limiter.try_acquire(): # never blocks; returns False when full
|
|
87
|
+
do_work()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Testing without real time
|
|
91
|
+
|
|
92
|
+
`clock` (returns seconds) and `sleep` (waits seconds) are injectable, so a limiter runs
|
|
93
|
+
instantly under a fake clock:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
now = [0.0]
|
|
97
|
+
slept: list[float] = []
|
|
98
|
+
|
|
99
|
+
limiter = RateLimiter(
|
|
100
|
+
calls=1, period=10,
|
|
101
|
+
clock=lambda: now[0],
|
|
102
|
+
sleep=lambda s: (slept.append(s), now.__setitem__(0, now[0] + s)),
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How it limits
|
|
107
|
+
|
|
108
|
+
taplimit uses a **sliding window**: it keeps the timestamps of recent calls and a slot
|
|
109
|
+
frees exactly `period` seconds after the call that took it. A burst never carries over a
|
|
110
|
+
fixed boundary the way a reset-every-minute counter does, so the rate you ask for is the
|
|
111
|
+
rate you get. All state is guarded by a lock, so one decorator or limiter is safe to share
|
|
112
|
+
across threads.
|
|
113
|
+
|
|
114
|
+
## Why
|
|
115
|
+
|
|
116
|
+
The simple niche of "let this function run at most N times per period, in one process,
|
|
117
|
+
synchronously" is oddly unserved:
|
|
118
|
+
|
|
119
|
+
- [`ratelimit`](https://pypi.org/project/ratelimit/) popularised the `@limits(calls, period)`
|
|
120
|
+
shape and is still downloaded millions of times a month, but it has been **abandoned since
|
|
121
|
+
2018** (last release 2.2.1). It also uses a fixed window and its threading story is thin.
|
|
122
|
+
- [`pyrate-limiter`](https://pypi.org/project/pyrate-limiter/) and
|
|
123
|
+
[`limits`](https://pypi.org/project/limits/) are maintained and capable, but they are
|
|
124
|
+
**backend-oriented and heavy**: buckets, optional SQLite / Redis stores, async machinery.
|
|
125
|
+
Overkill when all you want is a decorator on one function.
|
|
126
|
+
- [`limiter`](https://pypi.org/project/limiter/) is a token-bucket library with a different
|
|
127
|
+
`rate` / `capacity` API, not the plain calls-per-period shape.
|
|
128
|
+
|
|
129
|
+
taplimit is the small, modern, typed take: one decorator, a sliding window, thread-safe,
|
|
130
|
+
no dependencies, and a clock you can fake in tests. Reach for it when a single process needs
|
|
131
|
+
to stay under a rate and a full rate-limiting backend would be too much.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT. See [LICENSE](LICENSE).
|
|
136
|
+
|
|
137
|
+
## Links
|
|
138
|
+
|
|
139
|
+
- Website: https://lianes8studio.com
|
|
140
|
+
- GitHub: https://github.com/JaviLianes8
|
|
141
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
taplimit/__init__.py,sha256=nsOeDJxTsbZ1PU5TiVFhiLG2t4PNkfQiHHXgTG5XQi0,315
|
|
2
|
+
taplimit/_decorator.py,sha256=tjDTtAnMqFEuxnixm1SY13eBVygksJ4xfv44zAHeqkM,1417
|
|
3
|
+
taplimit/_errors.py,sha256=cVbmGNbJf17FpkV5J4zj7xbpBJ7o6JtnDDfJNBh92MA,545
|
|
4
|
+
taplimit/_limiter.py,sha256=cWGRz3E7Ip07bICPz42_TX4480Cepy9H6L2zw6WXYl0,3229
|
|
5
|
+
taplimit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
taplimit-0.1.0.dist-info/METADATA,sha256=X1nSHRmsSb7QwZqwSbik5stcD7dbwqf0MLKWPe430Ks,4606
|
|
7
|
+
taplimit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
taplimit-0.1.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
9
|
+
taplimit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|