throttlekit 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.
- throttlekit/__init__.py +2 -0
- throttlekit/leaky_limiter.py +72 -0
- throttlekit/limiter.py +104 -0
- throttlekit-0.1.0.dist-info/METADATA +205 -0
- throttlekit-0.1.0.dist-info/RECORD +6 -0
- throttlekit-0.1.0.dist-info/WHEEL +4 -0
throttlekit/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Callable, Awaitable, Optional, TypeVar, Union
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from typing import ParamSpec
|
|
8
|
+
except ImportError:
|
|
9
|
+
from typing_extensions import ParamSpec # type: ignore
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
|
|
14
|
+
class LeakyBucketRateLimiter:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
rate: float = 1.0, # requests per second
|
|
18
|
+
max_queue_size: int = 100,
|
|
19
|
+
logger: Optional[logging.Logger] = None
|
|
20
|
+
) -> None:
|
|
21
|
+
if rate <= 0:
|
|
22
|
+
raise ValueError("rate must be > 0")
|
|
23
|
+
self.leak_interval = 1.0 / rate # convert to delay between each request
|
|
24
|
+
self.queue: asyncio.Queue[asyncio.Future[None]] = asyncio.Queue(maxsize=max_queue_size)
|
|
25
|
+
self._started = False
|
|
26
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
27
|
+
self._drain_task: Optional[asyncio.Task] = None
|
|
28
|
+
|
|
29
|
+
async def start(self) -> None:
|
|
30
|
+
if self._started:
|
|
31
|
+
return
|
|
32
|
+
self._started = True
|
|
33
|
+
self._drain_task = asyncio.create_task(self._drain_loop())
|
|
34
|
+
self._logger.debug("LeakyBucket started with leak_interval=%s", self.leak_interval)
|
|
35
|
+
|
|
36
|
+
async def _drain_loop(self) -> None:
|
|
37
|
+
while True:
|
|
38
|
+
fut = await self.queue.get()
|
|
39
|
+
if not fut.done():
|
|
40
|
+
fut.set_result(None)
|
|
41
|
+
await asyncio.sleep(self.leak_interval)
|
|
42
|
+
|
|
43
|
+
async def acquire(self) -> None:
|
|
44
|
+
if not self._started:
|
|
45
|
+
raise RuntimeError("LeakyBucket not started. Call await limiter.start() before use.")
|
|
46
|
+
fut: asyncio.Future[None] = asyncio.get_event_loop().create_future()
|
|
47
|
+
await self.queue.put(fut)
|
|
48
|
+
await fut
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> "LeakyBucketRateLimiter":
|
|
51
|
+
await self.acquire()
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def limit(
|
|
58
|
+
self,
|
|
59
|
+
fn: Optional[Callable[..., Awaitable[T]]] = None
|
|
60
|
+
) -> Union[
|
|
61
|
+
Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]],
|
|
62
|
+
Callable[..., Awaitable[T]]
|
|
63
|
+
]:
|
|
64
|
+
|
|
65
|
+
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
66
|
+
@wraps(func)
|
|
67
|
+
async def wrapper(*args, **kwargs) -> T:
|
|
68
|
+
await self.acquire()
|
|
69
|
+
return await func(*args, **kwargs)
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
return decorator if fn is None else decorator(fn)
|
throttlekit/limiter.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Callable, Awaitable, TypeVar, Optional, Union
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from typing import ParamSpec
|
|
8
|
+
except ImportError:
|
|
9
|
+
from typing_extensions import ParamSpec # type: ignore
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
|
|
14
|
+
class TokenBucketRateLimiter:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
max_tokens: int,
|
|
18
|
+
refill_interval: float = 1.0,
|
|
19
|
+
concurrency_limit: Optional[int] = None,
|
|
20
|
+
logger: Optional[logging.Logger] = None
|
|
21
|
+
) -> None:
|
|
22
|
+
self.max_tokens: int = max_tokens
|
|
23
|
+
self.refill_interval: float = refill_interval
|
|
24
|
+
self.bucket: asyncio.Queue[int] = asyncio.Queue(maxsize=max_tokens)
|
|
25
|
+
self.semaphore: Optional[asyncio.Semaphore] = asyncio.Semaphore(concurrency_limit) if concurrency_limit else None
|
|
26
|
+
self.started: bool = False
|
|
27
|
+
self.logger: logging.Logger = logger or logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
async def start(self) -> None:
|
|
30
|
+
if self.started:
|
|
31
|
+
return
|
|
32
|
+
self.started = True
|
|
33
|
+
for _ in range(self.max_tokens):
|
|
34
|
+
self.bucket.put_nowait(1)
|
|
35
|
+
asyncio.create_task(self._refill())
|
|
36
|
+
self.logger.debug("Rate limiter started with %d tokens", self.max_tokens)
|
|
37
|
+
|
|
38
|
+
async def _refill(self) -> None:
|
|
39
|
+
while True:
|
|
40
|
+
await asyncio.sleep(self.refill_interval)
|
|
41
|
+
for _ in range(self.max_tokens - self.bucket.qsize()):
|
|
42
|
+
try:
|
|
43
|
+
self.bucket.put_nowait(1)
|
|
44
|
+
except asyncio.QueueFull:
|
|
45
|
+
break
|
|
46
|
+
|
|
47
|
+
async def acquire(self) -> None:
|
|
48
|
+
if not self.started:
|
|
49
|
+
raise RuntimeError("RateLimiter not started. Call await limiter.start() before use.")
|
|
50
|
+
await self.bucket.get()
|
|
51
|
+
if self.semaphore:
|
|
52
|
+
await self.semaphore.acquire()
|
|
53
|
+
|
|
54
|
+
def release_token(self) -> None:
|
|
55
|
+
"""Refund a token manually (not normally used)."""
|
|
56
|
+
try:
|
|
57
|
+
self.bucket.put_nowait(1)
|
|
58
|
+
except asyncio.QueueFull:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def release_semaphore(self) -> None:
|
|
62
|
+
"""Manually release the semaphore if acquired outside context."""
|
|
63
|
+
if self.semaphore:
|
|
64
|
+
self.semaphore.release()
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> "TokenBucketRateLimiter":
|
|
67
|
+
await self.acquire()
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]:
|
|
71
|
+
self.release_semaphore()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def _release(self) -> None:
|
|
75
|
+
"""Internal helper for releasing either semaphore or bucket."""
|
|
76
|
+
self.release_semaphore()
|
|
77
|
+
self.release_token()
|
|
78
|
+
|
|
79
|
+
def limit(
|
|
80
|
+
self,
|
|
81
|
+
fn: Optional[Callable[..., Awaitable[T]]] = None,
|
|
82
|
+
*,
|
|
83
|
+
tokens: int = 1
|
|
84
|
+
) -> Union[
|
|
85
|
+
Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]],
|
|
86
|
+
Callable[..., Awaitable[T]]
|
|
87
|
+
]:
|
|
88
|
+
|
|
89
|
+
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
|
90
|
+
@wraps(func)
|
|
91
|
+
async def wrapper(*args, **kwargs) -> T:
|
|
92
|
+
for _ in range(tokens):
|
|
93
|
+
await self.acquire()
|
|
94
|
+
try:
|
|
95
|
+
return await func(*args, **kwargs)
|
|
96
|
+
finally:
|
|
97
|
+
for _ in range(tokens):
|
|
98
|
+
self._release()
|
|
99
|
+
return wrapper
|
|
100
|
+
|
|
101
|
+
if fn is None:
|
|
102
|
+
return decorator
|
|
103
|
+
else:
|
|
104
|
+
return decorator(fn)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: throttlekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast asyncio-compatible token bucket rate limiter
|
|
5
|
+
Author: Roudrasekhar Majumder
|
|
6
|
+
Author-email: Roudrasekhar Majumder <roudra25@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Dist: typing-extensions>=4.0.0 ; python_full_version < '3.10'
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# ๐ throttlekit
|
|
22
|
+
|
|
23
|
+
A lightweight, asyncio-based rate limiting library for Python that provides flexible and efficient rate limiting solutions.
|
|
24
|
+
|
|
25
|
+
## ๐ Overview
|
|
26
|
+
|
|
27
|
+
**throttlekit** offers two proven rate limiting algorithms:
|
|
28
|
+
|
|
29
|
+
- **โก TokenBucketRateLimiter**: Allows controlled bursts of activity
|
|
30
|
+
- **๐ง LeakyBucketRateLimiter**: Enforces a strict, steady rate
|
|
31
|
+
|
|
32
|
+
Perfect for API throttling, web scrapers, background jobs, and queue management.
|
|
33
|
+
|
|
34
|
+
## ๐ Features
|
|
35
|
+
|
|
36
|
+
- โ
**Two proven algorithms**: Token Bucket (burst-tolerant) and Leaky Bucket (evenly-paced)
|
|
37
|
+
- โ
**Multiple usage patterns**: `@decorator`, `async with`, and manual `.acquire()`
|
|
38
|
+
- โ
**Concurrency control**: Optional `concurrency_limit` parameter
|
|
39
|
+
- โ
**High performance**: Low-overhead design optimized for async workloads
|
|
40
|
+
- โ
**asyncio integration**: Works seamlessly with `asyncio.gather()` and `TaskGroup`
|
|
41
|
+
|
|
42
|
+
## โ๏ธ Installation
|
|
43
|
+
|
|
44
|
+
### Using uv (recommended)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv add throttlekit
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Using pip
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install throttlekit
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## โจ Quick Start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
from throttlekit import TokenBucketRateLimiter
|
|
61
|
+
|
|
62
|
+
# Create a rate limiter (5 tokens, refill every second)
|
|
63
|
+
limiter = TokenBucketRateLimiter(max_tokens=5, refill_interval=1.0)
|
|
64
|
+
|
|
65
|
+
@limiter.limit
|
|
66
|
+
async def call_api(i):
|
|
67
|
+
await asyncio.sleep(0.2)
|
|
68
|
+
return f"Request {i} completed"
|
|
69
|
+
|
|
70
|
+
async def main():
|
|
71
|
+
await limiter.start()
|
|
72
|
+
|
|
73
|
+
# Process 10 requests with rate limiting
|
|
74
|
+
results = await asyncio.gather(*(call_api(i) for i in range(10)))
|
|
75
|
+
print(results)
|
|
76
|
+
|
|
77
|
+
# Run the example
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## ๐ง Which Limiter Should I Use?
|
|
82
|
+
|
|
83
|
+
| Use Case | Recommended Limiter | Why? |
|
|
84
|
+
|----------|-------------------|------|
|
|
85
|
+
| Allow short bursts (e.g., 5 calls at once) | **Token Bucket** | Accumulates tokens for burst capacity |
|
|
86
|
+
| Require steady pacing (e.g., 1 call/sec max) | **Leaky Bucket** | Maintains consistent rate |
|
|
87
|
+
| Queue smoothing, task draining | **Leaky Bucket** | FIFO processing at fixed rate |
|
|
88
|
+
| Per-user or per-key API quotas | **Token Bucket** | Flexible burst handling |
|
|
89
|
+
|
|
90
|
+
## Rate Limiters
|
|
91
|
+
|
|
92
|
+
### TokenBucketRateLimiter
|
|
93
|
+
|
|
94
|
+
Allows bursts up to `max_tokens`, then refills at a steady rate.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from throttlekit import TokenBucketRateLimiter
|
|
98
|
+
|
|
99
|
+
limiter = TokenBucketRateLimiter(
|
|
100
|
+
max_tokens=10, # Maximum burst size
|
|
101
|
+
refill_interval=1.0, # Refill every second
|
|
102
|
+
concurrency_limit=5 # Optional: limit concurrent operations
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Supports:**
|
|
107
|
+
|
|
108
|
+
- `@limiter.limit` decorator
|
|
109
|
+
- `async with limiter` context manager
|
|
110
|
+
- `await limiter.acquire()` manual usage
|
|
111
|
+
|
|
112
|
+
### ๐ง LeakyBucketRateLimiter
|
|
113
|
+
|
|
114
|
+
Processes requests at a fixed rate, queuing excess requests.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from throttlekit import LeakyBucketRateLimiter
|
|
118
|
+
|
|
119
|
+
limiter = LeakyBucketRateLimiter(
|
|
120
|
+
rate=2.0, # 2 requests per second
|
|
121
|
+
max_queue_size=100 # Maximum queued requests
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Behavior:**
|
|
126
|
+
|
|
127
|
+
- Drains 1 request every `1/rate` seconds in FIFO order
|
|
128
|
+
- Queued requests are processed at a fixed rate
|
|
129
|
+
- Bursts are automatically queued (up to `max_queue_size`)
|
|
130
|
+
|
|
131
|
+
## ๐ Usage Examples
|
|
132
|
+
|
|
133
|
+
### 1๏ธโฃ Decorator Pattern
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@limiter.limit
|
|
137
|
+
async def fetch_data(url):
|
|
138
|
+
async with aiohttp.ClientSession() as session:
|
|
139
|
+
async with session.get(url) as response:
|
|
140
|
+
return await response.json()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 2๏ธโฃ Context Manager
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
async with limiter:
|
|
147
|
+
result = await expensive_operation()
|
|
148
|
+
return result
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 3๏ธโฃ Manual Control
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
await limiter.acquire()
|
|
155
|
+
try:
|
|
156
|
+
result = await do_work()
|
|
157
|
+
finally:
|
|
158
|
+
limiter.release_token() # For TokenBucket
|
|
159
|
+
limiter.release_semaphore() # If using concurrency_limit
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## ๐งช Testing
|
|
163
|
+
|
|
164
|
+
Install test dependencies:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
uv pip install pytest pytest-asyncio
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Run tests with coverage:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pytest --cov=src/throttlekit --cov-report=term-missing
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## ๐ Project Structure
|
|
177
|
+
|
|
178
|
+
```tree
|
|
179
|
+
throttlekit/
|
|
180
|
+
โโโ src/
|
|
181
|
+
โ โโโ throttlekit/
|
|
182
|
+
โ โโโ __init__.py
|
|
183
|
+
โ โโโ limiter.py # TokenBucketRateLimiter
|
|
184
|
+
โ โโโ leaky_limiter.py # LeakyBucketRateLimiter
|
|
185
|
+
โโโ tests/
|
|
186
|
+
โ โโโ test_token_bucket_limiter.py
|
|
187
|
+
โ โโโ test_leaky_bucket_limiter.py
|
|
188
|
+
โโโ pyproject.toml
|
|
189
|
+
โโโ README.md
|
|
190
|
+
โโโ LICENSE
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## ๐ License
|
|
194
|
+
|
|
195
|
+
MIT License ยฉ [Roudrasekhar Majumder](https://github.com/rowds)
|
|
196
|
+
|
|
197
|
+
## ๐ Contributing
|
|
198
|
+
|
|
199
|
+
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup instructions and guidelines.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
**โญ Star this repo if you find it useful!**
|
|
204
|
+
|
|
205
|
+
[Report Bug](https://github.com/rowds/throttlekit/issues) โข [Request Feature](https://github.com/rowds/throttlekit/issues) โข [Documentation](https://github.com/rowds/throttlekit)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
throttlekit/__init__.py,sha256=2313ef64e3548b7eb0bda268ebe765f06b3ea0f553388d0c773bbe71013cb69f,93
|
|
2
|
+
throttlekit/leaky_limiter.py,sha256=d356307f138e88fe2ca9289912ad237ad0ef56e26a3703b379dba7b9d3a3b6b0,2445
|
|
3
|
+
throttlekit/limiter.py,sha256=94a02abfad2f23fd0a1af5fc1cfa2f95f9579c28f98bb5fae30b8dd21a74b7ab,3427
|
|
4
|
+
throttlekit-0.1.0.dist-info/WHEEL,sha256=b70116f4076fa664af162441d2ba3754dbb4ec63e09d563bdc1e9ab023cce400,78
|
|
5
|
+
throttlekit-0.1.0.dist-info/METADATA,sha256=259b904e3388fbfdbb8c4813c96ab3be996a59af8f71fbdfcb308150dfd52a5c,5531
|
|
6
|
+
throttlekit-0.1.0.dist-info/RECORD,,
|