taplimit 0.1.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.
- taplimit-0.1.0/.gitattributes +1 -0
- taplimit-0.1.0/.github/FUNDING.yml +1 -0
- taplimit-0.1.0/.gitignore +12 -0
- taplimit-0.1.0/CHANGELOG.md +13 -0
- taplimit-0.1.0/CONTRIBUTING.md +19 -0
- taplimit-0.1.0/LICENSE +21 -0
- taplimit-0.1.0/PKG-INFO +141 -0
- taplimit-0.1.0/README.md +114 -0
- taplimit-0.1.0/pyproject.toml +51 -0
- taplimit-0.1.0/requirements-dev.txt +3 -0
- taplimit-0.1.0/scripts/check.ps1 +7 -0
- taplimit-0.1.0/scripts/release.ps1 +26 -0
- taplimit-0.1.0/src/taplimit/__init__.py +11 -0
- taplimit-0.1.0/src/taplimit/_decorator.py +44 -0
- taplimit-0.1.0/src/taplimit/_errors.py +15 -0
- taplimit-0.1.0/src/taplimit/_limiter.py +103 -0
- taplimit-0.1.0/src/taplimit/py.typed +0 -0
- taplimit-0.1.0/tests/_fakeclock.py +21 -0
- taplimit-0.1.0/tests/test_contract.py +26 -0
- taplimit-0.1.0/tests/test_decorator.py +59 -0
- taplimit-0.1.0/tests/test_limiter.py +146 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
buy_me_a_coffee: jlianesglrs
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0]
|
|
10
|
+
|
|
11
|
+
- First release: `rate_limit` decorator, `RateLimiter` object, and `RateLimitExceeded`.
|
|
12
|
+
- Sliding-window algorithm, thread-safe, zero dependencies.
|
|
13
|
+
- Block-or-raise behaviour, with injectable clock and sleep for tests.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/ratekeeper/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change. Tests inject time, disk and network; they
|
|
15
|
+
never touch the real thing.
|
|
16
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
|
|
17
|
+
- One logical change per commit, imperative subject line.
|
|
18
|
+
|
|
19
|
+
Open an issue first if the change is large or shifts the public API.
|
taplimit-0.1.0/LICENSE
ADDED
|
@@ -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.
|
taplimit-0.1.0/PKG-INFO
ADDED
|
@@ -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/
|
taplimit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# taplimit
|
|
2
|
+
|
|
3
|
+
Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise.
|
|
4
|
+
|
|
5
|
+
Zero dependencies, one obvious decorator, and a reusable limiter object when you need to
|
|
6
|
+
share a budget. The clock and sleep are injectable, so tests never wait on real time.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
pip install taplimit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Cap a function at N calls per period. By default a call over the limit blocks until a
|
|
17
|
+
slot frees:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from taplimit import rate_limit
|
|
21
|
+
|
|
22
|
+
@rate_limit(calls=15, period=60)
|
|
23
|
+
def call_api() -> None:
|
|
24
|
+
...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Prefer to fail fast instead of waiting? Ask it to raise:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from taplimit import rate_limit, RateLimitExceeded
|
|
31
|
+
|
|
32
|
+
@rate_limit(calls=15, period=60, on_limit="raise")
|
|
33
|
+
def call_api() -> None:
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
call_api()
|
|
38
|
+
except RateLimitExceeded as exc:
|
|
39
|
+
print(f"slow down, retry in {exc.retry_after:.1f}s")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Share one budget across several functions, or control slots by hand, with the limiter
|
|
43
|
+
object. It is a decorator, a context manager, and exposes `acquire` / `try_acquire`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from taplimit import RateLimiter
|
|
47
|
+
|
|
48
|
+
limiter = RateLimiter(calls=100, period=60)
|
|
49
|
+
|
|
50
|
+
@limiter
|
|
51
|
+
def read() -> None: ...
|
|
52
|
+
|
|
53
|
+
@limiter
|
|
54
|
+
def write() -> None: ... # read and write draw on the same 100-per-minute budget
|
|
55
|
+
|
|
56
|
+
with limiter: # guard a block of code
|
|
57
|
+
do_work()
|
|
58
|
+
|
|
59
|
+
if limiter.try_acquire(): # never blocks; returns False when full
|
|
60
|
+
do_work()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Testing without real time
|
|
64
|
+
|
|
65
|
+
`clock` (returns seconds) and `sleep` (waits seconds) are injectable, so a limiter runs
|
|
66
|
+
instantly under a fake clock:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
now = [0.0]
|
|
70
|
+
slept: list[float] = []
|
|
71
|
+
|
|
72
|
+
limiter = RateLimiter(
|
|
73
|
+
calls=1, period=10,
|
|
74
|
+
clock=lambda: now[0],
|
|
75
|
+
sleep=lambda s: (slept.append(s), now.__setitem__(0, now[0] + s)),
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## How it limits
|
|
80
|
+
|
|
81
|
+
taplimit uses a **sliding window**: it keeps the timestamps of recent calls and a slot
|
|
82
|
+
frees exactly `period` seconds after the call that took it. A burst never carries over a
|
|
83
|
+
fixed boundary the way a reset-every-minute counter does, so the rate you ask for is the
|
|
84
|
+
rate you get. All state is guarded by a lock, so one decorator or limiter is safe to share
|
|
85
|
+
across threads.
|
|
86
|
+
|
|
87
|
+
## Why
|
|
88
|
+
|
|
89
|
+
The simple niche of "let this function run at most N times per period, in one process,
|
|
90
|
+
synchronously" is oddly unserved:
|
|
91
|
+
|
|
92
|
+
- [`ratelimit`](https://pypi.org/project/ratelimit/) popularised the `@limits(calls, period)`
|
|
93
|
+
shape and is still downloaded millions of times a month, but it has been **abandoned since
|
|
94
|
+
2018** (last release 2.2.1). It also uses a fixed window and its threading story is thin.
|
|
95
|
+
- [`pyrate-limiter`](https://pypi.org/project/pyrate-limiter/) and
|
|
96
|
+
[`limits`](https://pypi.org/project/limits/) are maintained and capable, but they are
|
|
97
|
+
**backend-oriented and heavy**: buckets, optional SQLite / Redis stores, async machinery.
|
|
98
|
+
Overkill when all you want is a decorator on one function.
|
|
99
|
+
- [`limiter`](https://pypi.org/project/limiter/) is a token-bucket library with a different
|
|
100
|
+
`rate` / `capacity` API, not the plain calls-per-period shape.
|
|
101
|
+
|
|
102
|
+
taplimit is the small, modern, typed take: one decorator, a sliding window, thread-safe,
|
|
103
|
+
no dependencies, and a clock you can fake in tests. Reach for it when a single process needs
|
|
104
|
+
to stay under a rate and a full rate-limiting backend would be too much.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT. See [LICENSE](LICENSE).
|
|
109
|
+
|
|
110
|
+
## Links
|
|
111
|
+
|
|
112
|
+
- Website: https://lianes8studio.com
|
|
113
|
+
- GitHub: https://github.com/JaviLianes8
|
|
114
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "taplimit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Small, typed, thread-safe in-process rate-limit decorator: N calls per period, block or raise"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
|
|
14
|
+
keywords = ["rate-limit", "ratelimit", "throttle", "decorator", "thread-safe", "sliding-window"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["ruff", "mypy", "pytest", "build", "twine"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://lianes8studio.com"
|
|
28
|
+
Repository = "https://github.com/JaviLianes8/taplimit"
|
|
29
|
+
Issues = "https://github.com/JaviLianes8/taplimit/issues"
|
|
30
|
+
Changelog = "https://github.com/JaviLianes8/taplimit/blob/main/CHANGELOG.md"
|
|
31
|
+
LinkedIn = "https://www.linkedin.com/in/jlianes/"
|
|
32
|
+
Funding = "https://buymeacoffee.com/jlianesglrs"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/taplimit"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py310"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint]
|
|
42
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
43
|
+
|
|
44
|
+
[tool.mypy]
|
|
45
|
+
python_version = "3.10"
|
|
46
|
+
strict = true
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["src"]
|
|
51
|
+
addopts = "-q"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Build and upload to PyPI. Run scripts/check.ps1 first.
|
|
2
|
+
# The name must not already exist on PyPI; the token is read from a secret store, never git.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/taplimit/json" | Out-Null
|
|
7
|
+
throw "The name 'taplimit' already exists on PyPI. Choose another before publishing."
|
|
8
|
+
} catch [System.Net.WebException], [Microsoft.PowerShell.Commands.HttpResponseException] {
|
|
9
|
+
if ($_.Exception.Response.StatusCode.value__ -ne 404) { throw }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
$token = $env:PYPI_TOKEN
|
|
13
|
+
if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
|
|
14
|
+
$token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
|
|
15
|
+
}
|
|
16
|
+
if (-not $token) {
|
|
17
|
+
throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
|
|
21
|
+
python -m build
|
|
22
|
+
python -m twine check dist/*
|
|
23
|
+
|
|
24
|
+
$env:TWINE_USERNAME = "__token__"
|
|
25
|
+
$env:TWINE_PASSWORD = $token
|
|
26
|
+
try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
|
|
@@ -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"]
|
|
@@ -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
|
|
@@ -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")
|
|
@@ -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
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""A deterministic clock and sleep pair so tests never wait on real time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeClock:
|
|
7
|
+
"""A fake monotonic clock whose ``sleep`` simply advances the clock."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
10
|
+
self.now = start
|
|
11
|
+
self.slept: list[float] = []
|
|
12
|
+
|
|
13
|
+
def time(self) -> float:
|
|
14
|
+
return self.now
|
|
15
|
+
|
|
16
|
+
def sleep(self, seconds: float) -> None:
|
|
17
|
+
self.slept.append(seconds)
|
|
18
|
+
self.now += seconds
|
|
19
|
+
|
|
20
|
+
def advance(self, seconds: float) -> None:
|
|
21
|
+
self.now += seconds
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
|
|
3
|
+
import taplimit
|
|
4
|
+
|
|
5
|
+
# The public API. Add every exported name here as the library grows; this list is the
|
|
6
|
+
# contract and the test below fails if the module and this list disagree.
|
|
7
|
+
PUBLIC_API: list[str] = ["RateLimitExceeded", "RateLimiter", "rate_limit"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_all_lists_exactly_the_public_api() -> None:
|
|
11
|
+
assert sorted(taplimit.__all__) == sorted(PUBLIC_API)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_every_exported_name_resolves() -> None:
|
|
15
|
+
for name in taplimit.__all__:
|
|
16
|
+
assert hasattr(taplimit, name)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_version_is_a_three_part_string() -> None:
|
|
20
|
+
parts = taplimit.__version__.split(".")
|
|
21
|
+
assert len(parts) == 3
|
|
22
|
+
assert all(part.isdigit() for part in parts)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_the_package_ships_type_information() -> None:
|
|
26
|
+
assert importlib.resources.files("taplimit").joinpath("py.typed").is_file()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Behaviour tests for the rate_limit decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from _fakeclock import FakeClock
|
|
7
|
+
|
|
8
|
+
from taplimit import RateLimitExceeded, rate_limit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_blocks_by_default_until_a_slot_frees() -> None:
|
|
12
|
+
clock = FakeClock()
|
|
13
|
+
|
|
14
|
+
@rate_limit(2, 10, clock=clock.time, sleep=clock.sleep)
|
|
15
|
+
def work() -> str:
|
|
16
|
+
return "ok"
|
|
17
|
+
|
|
18
|
+
assert work() == "ok"
|
|
19
|
+
assert work() == "ok"
|
|
20
|
+
assert work() == "ok" # third call waits
|
|
21
|
+
assert clock.slept == [10.0]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_raise_mode_raises_instead_of_waiting() -> None:
|
|
25
|
+
clock = FakeClock()
|
|
26
|
+
|
|
27
|
+
@rate_limit(1, 10, on_limit="raise", clock=clock.time, sleep=clock.sleep)
|
|
28
|
+
def work() -> str:
|
|
29
|
+
return "ok"
|
|
30
|
+
|
|
31
|
+
assert work() == "ok"
|
|
32
|
+
with pytest.raises(RateLimitExceeded):
|
|
33
|
+
work()
|
|
34
|
+
assert clock.slept == []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_preserves_arguments_and_return_value() -> None:
|
|
38
|
+
clock = FakeClock()
|
|
39
|
+
|
|
40
|
+
@rate_limit(10, 10, clock=clock.time, sleep=clock.sleep)
|
|
41
|
+
def add(a: int, b: int = 0) -> int:
|
|
42
|
+
return a + b
|
|
43
|
+
|
|
44
|
+
assert add(2, b=3) == 5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_preserves_the_wrapped_name_and_docstring() -> None:
|
|
48
|
+
@rate_limit(1, 1)
|
|
49
|
+
def greet() -> str:
|
|
50
|
+
"""Say hello."""
|
|
51
|
+
return "hi"
|
|
52
|
+
|
|
53
|
+
assert greet.__name__ == "greet"
|
|
54
|
+
assert greet.__doc__ == "Say hello."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_rejects_an_unknown_on_limit_value() -> None:
|
|
58
|
+
with pytest.raises(ValueError):
|
|
59
|
+
rate_limit(1, 10, on_limit="explode")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Behaviour tests for the RateLimiter primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from _fakeclock import FakeClock
|
|
9
|
+
|
|
10
|
+
from taplimit import RateLimiter, RateLimitExceeded
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_allows_up_to_the_limit_without_waiting() -> None:
|
|
14
|
+
clock = FakeClock()
|
|
15
|
+
limiter = RateLimiter(3, 60, clock=clock.time, sleep=clock.sleep)
|
|
16
|
+
|
|
17
|
+
for _ in range(3):
|
|
18
|
+
limiter.acquire()
|
|
19
|
+
|
|
20
|
+
assert clock.slept == []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_blocks_until_the_oldest_slot_frees() -> None:
|
|
24
|
+
clock = FakeClock()
|
|
25
|
+
limiter = RateLimiter(2, 10, clock=clock.time, sleep=clock.sleep)
|
|
26
|
+
|
|
27
|
+
limiter.acquire() # t=0
|
|
28
|
+
clock.advance(4)
|
|
29
|
+
limiter.acquire() # t=4, window now full
|
|
30
|
+
limiter.acquire() # must wait for the t=0 slot to free at t=10
|
|
31
|
+
|
|
32
|
+
assert clock.slept == [6.0]
|
|
33
|
+
assert clock.now == 10.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_try_acquire_reports_free_and_full() -> None:
|
|
37
|
+
clock = FakeClock()
|
|
38
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
39
|
+
|
|
40
|
+
assert limiter.try_acquire() is True
|
|
41
|
+
assert limiter.try_acquire() is False
|
|
42
|
+
clock.advance(10)
|
|
43
|
+
assert limiter.try_acquire() is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_try_acquire_does_not_wait() -> None:
|
|
47
|
+
clock = FakeClock()
|
|
48
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
49
|
+
|
|
50
|
+
limiter.try_acquire()
|
|
51
|
+
limiter.try_acquire()
|
|
52
|
+
|
|
53
|
+
assert clock.slept == []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_non_blocking_acquire_raises_with_retry_after() -> None:
|
|
57
|
+
clock = FakeClock()
|
|
58
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
59
|
+
|
|
60
|
+
limiter.acquire()
|
|
61
|
+
clock.advance(3)
|
|
62
|
+
with pytest.raises(RateLimitExceeded) as info:
|
|
63
|
+
limiter.acquire(blocking=False)
|
|
64
|
+
|
|
65
|
+
assert info.value.retry_after == pytest.approx(7.0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_rejected_call_does_not_consume_a_slot() -> None:
|
|
69
|
+
clock = FakeClock()
|
|
70
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
71
|
+
|
|
72
|
+
limiter.acquire()
|
|
73
|
+
with pytest.raises(RateLimitExceeded):
|
|
74
|
+
limiter.acquire(blocking=False)
|
|
75
|
+
clock.advance(10)
|
|
76
|
+
|
|
77
|
+
assert limiter.try_acquire() is True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_window_slides_rather_than_resetting() -> None:
|
|
81
|
+
clock = FakeClock()
|
|
82
|
+
limiter = RateLimiter(2, 10, clock=clock.time, sleep=clock.sleep)
|
|
83
|
+
|
|
84
|
+
limiter.acquire() # t=0
|
|
85
|
+
limiter.acquire() # t=0
|
|
86
|
+
clock.advance(10) # the two slots free exactly now
|
|
87
|
+
assert limiter.try_acquire() is True
|
|
88
|
+
assert limiter.try_acquire() is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_works_as_a_decorator() -> None:
|
|
92
|
+
clock = FakeClock()
|
|
93
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
94
|
+
calls = 0
|
|
95
|
+
|
|
96
|
+
@limiter
|
|
97
|
+
def ping() -> int:
|
|
98
|
+
nonlocal calls
|
|
99
|
+
calls += 1
|
|
100
|
+
return calls
|
|
101
|
+
|
|
102
|
+
assert ping() == 1
|
|
103
|
+
clock.advance(10)
|
|
104
|
+
assert ping() == 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_works_as_a_context_manager() -> None:
|
|
108
|
+
clock = FakeClock()
|
|
109
|
+
limiter = RateLimiter(1, 10, clock=clock.time, sleep=clock.sleep)
|
|
110
|
+
|
|
111
|
+
with limiter:
|
|
112
|
+
pass
|
|
113
|
+
clock.advance(10)
|
|
114
|
+
with limiter:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
assert clock.slept == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_rejects_non_positive_configuration() -> None:
|
|
121
|
+
with pytest.raises(ValueError):
|
|
122
|
+
RateLimiter(0, 10)
|
|
123
|
+
with pytest.raises(ValueError):
|
|
124
|
+
RateLimiter(1, 0)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_is_thread_safe_under_contention() -> None:
|
|
128
|
+
clock = FakeClock()
|
|
129
|
+
# Real sleep of zero keeps the test fast while exercising the lock.
|
|
130
|
+
limiter = RateLimiter(50, 1000, clock=clock.time)
|
|
131
|
+
taken = 0
|
|
132
|
+
lock = threading.Lock()
|
|
133
|
+
|
|
134
|
+
def worker() -> None:
|
|
135
|
+
nonlocal taken
|
|
136
|
+
if limiter.try_acquire():
|
|
137
|
+
with lock:
|
|
138
|
+
taken += 1
|
|
139
|
+
|
|
140
|
+
threads = [threading.Thread(target=worker) for _ in range(200)]
|
|
141
|
+
for thread in threads:
|
|
142
|
+
thread.start()
|
|
143
|
+
for thread in threads:
|
|
144
|
+
thread.join()
|
|
145
|
+
|
|
146
|
+
assert taken == 50
|