metered 1.0.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.
- metered-1.0.0/.github/workflows/ci.yml +42 -0
- metered-1.0.0/.gitignore +30 -0
- metered-1.0.0/LICENSE +21 -0
- metered-1.0.0/PKG-INFO +275 -0
- metered-1.0.0/README.md +218 -0
- metered-1.0.0/benchmarks/locustfile.py +24 -0
- metered-1.0.0/docs/index.md +14 -0
- metered-1.0.0/docs/migration.md +70 -0
- metered-1.0.0/examples/fastapi_llm_cost.py +37 -0
- metered-1.0.0/examples/fastapi_saas.py +48 -0
- metered-1.0.0/examples/flask_basic.py +27 -0
- metered-1.0.0/examples/multiple_limits.py +22 -0
- metered-1.0.0/examples/webhook_event_handler.py +44 -0
- metered-1.0.0/metered/__init__.py +17 -0
- metered-1.0.0/metered/analytics/__init__.py +4 -0
- metered-1.0.0/metered/analytics/models.py +15 -0
- metered-1.0.0/metered/analytics/tracker.py +30 -0
- metered-1.0.0/metered/backends/memory.py +174 -0
- metered-1.0.0/metered/backends/redis.py +249 -0
- metered-1.0.0/metered/core/backends.py +19 -0
- metered-1.0.0/metered/core/limiter.py +188 -0
- metered-1.0.0/metered/core/quota.py +171 -0
- metered-1.0.0/metered/core/types.py +48 -0
- metered-1.0.0/metered/hooks/events.py +162 -0
- metered-1.0.0/metered/integrations/fastapi.py +38 -0
- metered-1.0.0/metered/integrations/flask.py +25 -0
- metered-1.0.0/mkdocs.yml +10 -0
- metered-1.0.0/pyproject.toml +39 -0
- metered-1.0.0/tests/integration/test_fastapi.py +32 -0
- metered-1.0.0/tests/integration/test_flask.py +33 -0
- metered-1.0.0/tests/integration/test_redis_backend.py +22 -0
- metered-1.0.0/tests/integration/test_redis_concurrency.py +63 -0
- metered-1.0.0/tests/unit/test_memory_algorithms.py +51 -0
- metered-1.0.0/tests/unit/test_memory_backend.py +23 -0
- metered-1.0.0/tests/unit/test_quota.py +57 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "master", "main" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "master", "main" ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
services:
|
|
17
|
+
redis:
|
|
18
|
+
image: redis:7
|
|
19
|
+
ports:
|
|
20
|
+
- 6379:6379
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: ${{ matrix.python-version }}
|
|
29
|
+
cache: 'pip'
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade pip
|
|
34
|
+
pip install -e ".[dev]"
|
|
35
|
+
|
|
36
|
+
- name: Check typing with mypy
|
|
37
|
+
run: |
|
|
38
|
+
mypy metered/
|
|
39
|
+
|
|
40
|
+
- name: Test with pytest
|
|
41
|
+
run: |
|
|
42
|
+
pytest
|
metered-1.0.0/.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Environments
|
|
7
|
+
.env
|
|
8
|
+
.venv
|
|
9
|
+
env/
|
|
10
|
+
venv/
|
|
11
|
+
ENV/
|
|
12
|
+
env.bak/
|
|
13
|
+
venv.bak/
|
|
14
|
+
|
|
15
|
+
# IDEs and Editors
|
|
16
|
+
.vscode/
|
|
17
|
+
.idea/
|
|
18
|
+
*.swp
|
|
19
|
+
*.swo
|
|
20
|
+
|
|
21
|
+
# Testing, reporting, and coverage
|
|
22
|
+
.coverage
|
|
23
|
+
.tox/
|
|
24
|
+
.pytest_cache/
|
|
25
|
+
htmlcov/
|
|
26
|
+
|
|
27
|
+
# Distribution / packaging
|
|
28
|
+
build/
|
|
29
|
+
dist/
|
|
30
|
+
*.egg-info/
|
metered-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aris Supriyanto
|
|
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.
|
metered-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: metered
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Enterprise-grade, asynchronous rate limiting and quota management library for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arissupriy/metered
|
|
6
|
+
Project-URL: Repository, https://github.com/arissupriy/metered
|
|
7
|
+
Author-email: Aris Supriyanto <aris.jrj@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: asyncio,fastapi,flask,llm-cost,quota,rate-limit,redis
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Provides-Extra: dashboard
|
|
22
|
+
Requires-Dist: plotly; extra == 'dashboard'
|
|
23
|
+
Requires-Dist: starlette-admin; extra == 'dashboard'
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: fakeredis; extra == 'dev'
|
|
26
|
+
Requires-Dist: httpx2; extra == 'dev'
|
|
27
|
+
Requires-Dist: locust; extra == 'dev'
|
|
28
|
+
Requires-Dist: lupa; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
33
|
+
Provides-Extra: fastapi
|
|
34
|
+
Requires-Dist: fastapi>=0.139.2; extra == 'fastapi'
|
|
35
|
+
Requires-Dist: httpx2; extra == 'fastapi'
|
|
36
|
+
Provides-Extra: flask
|
|
37
|
+
Requires-Dist: flask[async]>=3.1.3; extra == 'flask'
|
|
38
|
+
Requires-Dist: werkzeug>=2.0; extra == 'flask'
|
|
39
|
+
Provides-Extra: full
|
|
40
|
+
Requires-Dist: fakeredis; extra == 'full'
|
|
41
|
+
Requires-Dist: fastapi>=0.139.2; extra == 'full'
|
|
42
|
+
Requires-Dist: flask[async]>=3.1.3; extra == 'full'
|
|
43
|
+
Requires-Dist: httpx2; extra == 'full'
|
|
44
|
+
Requires-Dist: locust; extra == 'full'
|
|
45
|
+
Requires-Dist: lupa; extra == 'full'
|
|
46
|
+
Requires-Dist: mypy; extra == 'full'
|
|
47
|
+
Requires-Dist: plotly; extra == 'full'
|
|
48
|
+
Requires-Dist: pytest; extra == 'full'
|
|
49
|
+
Requires-Dist: pytest-asyncio; extra == 'full'
|
|
50
|
+
Requires-Dist: pytest-cov; extra == 'full'
|
|
51
|
+
Requires-Dist: redis[hiredis]>=4.0; extra == 'full'
|
|
52
|
+
Requires-Dist: starlette-admin; extra == 'full'
|
|
53
|
+
Requires-Dist: werkzeug>=2.0; extra == 'full'
|
|
54
|
+
Provides-Extra: redis
|
|
55
|
+
Requires-Dist: redis[hiredis]>=4.0; extra == 'redis'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# Metered ⏱️
|
|
59
|
+
|
|
60
|
+
[](https://www.python.org/downloads/)
|
|
61
|
+
[](https://opensource.org/licenses/MIT)
|
|
62
|
+
[](https://github.com/psf/black)
|
|
63
|
+
|
|
64
|
+
**Metered** is an enterprise-grade, asynchronous rate limiting and quota management library for Python. Designed for modern SaaS architectures, it supports FastAPI and Flask natively, offering highly precise algorithms, dynamic LLM token costing, and robust distributed state management via Redis.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 🚀 Features
|
|
69
|
+
|
|
70
|
+
- **4 Core Rate Limiting Algorithms**:
|
|
71
|
+
- Token Bucket (Smooth bursting)
|
|
72
|
+
- Sliding Window (High precision, dynamic costs)
|
|
73
|
+
- Fixed Window (Standard quota tracking)
|
|
74
|
+
- Leaky Bucket (Strict egress shaping)
|
|
75
|
+
- **Dynamic Costing**: Perfect for GenAI/LLM wrappers—calculate the cost (tokens) of a request dynamically at runtime.
|
|
76
|
+
- **Quota Persistence**: Define limits like "10,000 tokens per month" that survive app restarts and synchronize globally via Redis.
|
|
77
|
+
- **Event-Driven Architecture**: Native webhook/event dispatcher with DLQ (Dead Letter Queue), exponential backoff, and strict event throttling to prevent spam when quotas are breached.
|
|
78
|
+
- **Backend Agnostic**: Ships with a thread-safe `InMemoryBackend` for development and an atomic, Lua-powered `RedisBackend` for high-throughput production.
|
|
79
|
+
- **Async Native**: Built with `asyncio` from the ground up, guaranteeing non-blocking behavior.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 📦 Installation
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install metered
|
|
87
|
+
```
|
|
88
|
+
*(Coming soon to PyPI)*
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## ⚡ Quick Start
|
|
93
|
+
|
|
94
|
+
### FastAPI Example
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from fastapi import FastAPI, Request
|
|
98
|
+
from metered import Metered, Strategy, IdentifierType
|
|
99
|
+
|
|
100
|
+
app = FastAPI()
|
|
101
|
+
# By default, uses the lightweight InMemoryBackend
|
|
102
|
+
meter = Metered()
|
|
103
|
+
|
|
104
|
+
# Helper to identify users
|
|
105
|
+
def get_client_ip(req: Request) -> str:
|
|
106
|
+
return req.client.host if req.client else "127.0.0.1"
|
|
107
|
+
|
|
108
|
+
# Restrict to 5 requests per 10 seconds per IP
|
|
109
|
+
@app.get("/api/data")
|
|
110
|
+
@meter.limit(
|
|
111
|
+
max_tokens=5,
|
|
112
|
+
period=10,
|
|
113
|
+
strategy=Strategy.FIXED_WINDOW,
|
|
114
|
+
identifier=get_client_ip
|
|
115
|
+
)
|
|
116
|
+
async def get_data(request: Request):
|
|
117
|
+
return {"data": "Success!"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Flask Example
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from flask import Flask, request, jsonify
|
|
124
|
+
from metered import Metered, Strategy
|
|
125
|
+
|
|
126
|
+
app = Flask(__name__)
|
|
127
|
+
meter = Metered()
|
|
128
|
+
|
|
129
|
+
def get_client_ip(req):
|
|
130
|
+
return req.remote_addr or "127.0.0.1"
|
|
131
|
+
|
|
132
|
+
@app.route("/api/data", methods=["GET"])
|
|
133
|
+
@meter.limit(max_tokens=5, period=10, strategy=Strategy.FIXED_WINDOW, identifier=get_client_ip)
|
|
134
|
+
def get_data():
|
|
135
|
+
return jsonify({"data": "Here is your data!"})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 🧠 Core Concepts
|
|
141
|
+
|
|
142
|
+
### Stacking Decorators
|
|
143
|
+
You can combine multiple constraints on a single endpoint. Metered evaluates them top-down (or as passed) and enforces the strictest rule.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
@app.get("/search")
|
|
147
|
+
@meter.quota(plan_name="pro_plan", identifier=get_user_id) # Evaluated first
|
|
148
|
+
@meter.limit(max_tokens=100, period=60, strategy=Strategy.SLIDING_WINDOW, identifier=get_user_id) # Sustained limit
|
|
149
|
+
@meter.limit(max_tokens=10, period=1, strategy=Strategy.SLIDING_WINDOW, identifier=get_user_id) # Burst limit
|
|
150
|
+
async def search(request: Request):
|
|
151
|
+
return {"results": []}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Dynamic Costs (LLMs / AI Apps)
|
|
155
|
+
Unlike standard rate limiters where 1 Request = 1 Token, `metered` allows you to define a `cost` function.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
async def calculate_llm_cost(req: Request) -> int:
|
|
159
|
+
body = await req.json()
|
|
160
|
+
prompt = body.get("prompt", "")
|
|
161
|
+
# e.g., 1 word = ~1.3 tokens
|
|
162
|
+
return max(1, int(len(prompt.split()) * 1.3))
|
|
163
|
+
|
|
164
|
+
@app.post("/v1/completions")
|
|
165
|
+
@meter.limit(
|
|
166
|
+
max_tokens=5000,
|
|
167
|
+
period=60,
|
|
168
|
+
strategy=Strategy.TOKEN_BUCKET,
|
|
169
|
+
identifier=get_api_key,
|
|
170
|
+
cost=calculate_llm_cost
|
|
171
|
+
)
|
|
172
|
+
async def generate_text(request: Request):
|
|
173
|
+
return {"text": "AI response..."}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 🏭 Production Guide
|
|
179
|
+
|
|
180
|
+
### Using Redis (Recommended for Production)
|
|
181
|
+
|
|
182
|
+
For distributed systems and multi-worker deployments (e.g., Uvicorn/Gunicorn), use the `RedisBackend`. It relies entirely on atomic Lua scripts to prevent race conditions.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from redis.asyncio import Redis
|
|
186
|
+
from metered import Metered, RedisBackend
|
|
187
|
+
|
|
188
|
+
redis_client = Redis.from_url("redis://localhost:6379", decode_responses=True)
|
|
189
|
+
redis_backend = RedisBackend(redis_client)
|
|
190
|
+
|
|
191
|
+
meter = Metered(backend=redis_backend, quota_backend=redis_backend)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Quota Engine & Plans
|
|
195
|
+
Unlike short-lived rate limits, Quotas are persistent billing boundaries (e.g., Monthly API usage).
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
# During your app startup phase
|
|
199
|
+
@app.on_event("startup")
|
|
200
|
+
async def startup():
|
|
201
|
+
# Store a persistent quota plan into the backend
|
|
202
|
+
await meter.quotas.set_plan(
|
|
203
|
+
target="user_123",
|
|
204
|
+
plan_name="pro_plan",
|
|
205
|
+
limit=100000, # 100k tokens
|
|
206
|
+
reset_period="monthly", # resets on the 1st of every month
|
|
207
|
+
identifier_type=IdentifierType.USER_ID
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Event Dispatcher (Alerts & Webhooks)
|
|
212
|
+
Metered includes an advanced event dispatcher with two-phase commits, throttling, and a DLQ (Dead Letter Queue) to reliably notify you (e.g. via Slack) when users approach their limits without spamming your network.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
# 1. Configure Persistent Outbox
|
|
216
|
+
meter.events.configure(
|
|
217
|
+
backend=redis_backend,
|
|
218
|
+
persist=True,
|
|
219
|
+
cooldown_seconds=3600 # Only alert once per hour per user
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# 2. Start the Background Worker
|
|
223
|
+
asyncio.create_task(meter.events.start_worker())
|
|
224
|
+
|
|
225
|
+
# 3. Listen to Quota Warnings
|
|
226
|
+
@meter.events.on_quota_warning(threshold=0.8)
|
|
227
|
+
async def handle_quota_warning(target: str, plan_name: str, usage_ratio: float, remaining: int):
|
|
228
|
+
# This handler will be retried with exponential backoff if it fails
|
|
229
|
+
await send_slack_alert(f"User {target} is at {usage_ratio*100}% of their {plan_name} plan.")
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 📊 Performance & Benchmarks
|
|
235
|
+
|
|
236
|
+
Metered is built to sustain massive concurrency. Below is a benchmark result using `locust` against the `fastapi_saas.py` example (which includes dynamic Redis Quotas, Rate Limits, and Event Dispatching):
|
|
237
|
+
|
|
238
|
+
```text
|
|
239
|
+
Type Name # reqs # fails | Avg Min Max Med | req/s failures/s
|
|
240
|
+
--------|---------------------------------------------------------------------------------------------------|--------|-----------
|
|
241
|
+
GET /premium-data 7865 0(0.00%) | 104 2 349 100 | 530.32 0.00
|
|
242
|
+
```
|
|
243
|
+
*(Tested with 100 concurrent headless users. Zero failures or unhandled exceptions).*
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 🌍 Global Middleware
|
|
248
|
+
If you want to apply limits globally rather than per-route, `metered` exports standard middlewares.
|
|
249
|
+
|
|
250
|
+
**FastAPI:**
|
|
251
|
+
```python
|
|
252
|
+
from metered.integrations.fastapi import MeteredMiddleware
|
|
253
|
+
from metered import Limit, Strategy
|
|
254
|
+
|
|
255
|
+
# Limits all traffic globally to 100 req/sec
|
|
256
|
+
app.add_middleware(
|
|
257
|
+
MeteredMiddleware,
|
|
258
|
+
meter=meter,
|
|
259
|
+
limits=[Limit(max_tokens=100, period=1, strategy=Strategy.FIXED_WINDOW)]
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 🤝 Contributing
|
|
266
|
+
Contributions are highly welcomed! Please check our open issues.
|
|
267
|
+
1. Fork the Project
|
|
268
|
+
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
269
|
+
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
270
|
+
4. Run static checks (`mypy metered/`)
|
|
271
|
+
5. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
272
|
+
6. Open a Pull Request
|
|
273
|
+
|
|
274
|
+
## 📄 License
|
|
275
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
metered-1.0.0/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Metered ⏱️
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://github.com/psf/black)
|
|
6
|
+
|
|
7
|
+
**Metered** is an enterprise-grade, asynchronous rate limiting and quota management library for Python. Designed for modern SaaS architectures, it supports FastAPI and Flask natively, offering highly precise algorithms, dynamic LLM token costing, and robust distributed state management via Redis.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🚀 Features
|
|
12
|
+
|
|
13
|
+
- **4 Core Rate Limiting Algorithms**:
|
|
14
|
+
- Token Bucket (Smooth bursting)
|
|
15
|
+
- Sliding Window (High precision, dynamic costs)
|
|
16
|
+
- Fixed Window (Standard quota tracking)
|
|
17
|
+
- Leaky Bucket (Strict egress shaping)
|
|
18
|
+
- **Dynamic Costing**: Perfect for GenAI/LLM wrappers—calculate the cost (tokens) of a request dynamically at runtime.
|
|
19
|
+
- **Quota Persistence**: Define limits like "10,000 tokens per month" that survive app restarts and synchronize globally via Redis.
|
|
20
|
+
- **Event-Driven Architecture**: Native webhook/event dispatcher with DLQ (Dead Letter Queue), exponential backoff, and strict event throttling to prevent spam when quotas are breached.
|
|
21
|
+
- **Backend Agnostic**: Ships with a thread-safe `InMemoryBackend` for development and an atomic, Lua-powered `RedisBackend` for high-throughput production.
|
|
22
|
+
- **Async Native**: Built with `asyncio` from the ground up, guaranteeing non-blocking behavior.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 📦 Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install metered
|
|
30
|
+
```
|
|
31
|
+
*(Coming soon to PyPI)*
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## ⚡ Quick Start
|
|
36
|
+
|
|
37
|
+
### FastAPI Example
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from fastapi import FastAPI, Request
|
|
41
|
+
from metered import Metered, Strategy, IdentifierType
|
|
42
|
+
|
|
43
|
+
app = FastAPI()
|
|
44
|
+
# By default, uses the lightweight InMemoryBackend
|
|
45
|
+
meter = Metered()
|
|
46
|
+
|
|
47
|
+
# Helper to identify users
|
|
48
|
+
def get_client_ip(req: Request) -> str:
|
|
49
|
+
return req.client.host if req.client else "127.0.0.1"
|
|
50
|
+
|
|
51
|
+
# Restrict to 5 requests per 10 seconds per IP
|
|
52
|
+
@app.get("/api/data")
|
|
53
|
+
@meter.limit(
|
|
54
|
+
max_tokens=5,
|
|
55
|
+
period=10,
|
|
56
|
+
strategy=Strategy.FIXED_WINDOW,
|
|
57
|
+
identifier=get_client_ip
|
|
58
|
+
)
|
|
59
|
+
async def get_data(request: Request):
|
|
60
|
+
return {"data": "Success!"}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Flask Example
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from flask import Flask, request, jsonify
|
|
67
|
+
from metered import Metered, Strategy
|
|
68
|
+
|
|
69
|
+
app = Flask(__name__)
|
|
70
|
+
meter = Metered()
|
|
71
|
+
|
|
72
|
+
def get_client_ip(req):
|
|
73
|
+
return req.remote_addr or "127.0.0.1"
|
|
74
|
+
|
|
75
|
+
@app.route("/api/data", methods=["GET"])
|
|
76
|
+
@meter.limit(max_tokens=5, period=10, strategy=Strategy.FIXED_WINDOW, identifier=get_client_ip)
|
|
77
|
+
def get_data():
|
|
78
|
+
return jsonify({"data": "Here is your data!"})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🧠 Core Concepts
|
|
84
|
+
|
|
85
|
+
### Stacking Decorators
|
|
86
|
+
You can combine multiple constraints on a single endpoint. Metered evaluates them top-down (or as passed) and enforces the strictest rule.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
@app.get("/search")
|
|
90
|
+
@meter.quota(plan_name="pro_plan", identifier=get_user_id) # Evaluated first
|
|
91
|
+
@meter.limit(max_tokens=100, period=60, strategy=Strategy.SLIDING_WINDOW, identifier=get_user_id) # Sustained limit
|
|
92
|
+
@meter.limit(max_tokens=10, period=1, strategy=Strategy.SLIDING_WINDOW, identifier=get_user_id) # Burst limit
|
|
93
|
+
async def search(request: Request):
|
|
94
|
+
return {"results": []}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Dynamic Costs (LLMs / AI Apps)
|
|
98
|
+
Unlike standard rate limiters where 1 Request = 1 Token, `metered` allows you to define a `cost` function.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
async def calculate_llm_cost(req: Request) -> int:
|
|
102
|
+
body = await req.json()
|
|
103
|
+
prompt = body.get("prompt", "")
|
|
104
|
+
# e.g., 1 word = ~1.3 tokens
|
|
105
|
+
return max(1, int(len(prompt.split()) * 1.3))
|
|
106
|
+
|
|
107
|
+
@app.post("/v1/completions")
|
|
108
|
+
@meter.limit(
|
|
109
|
+
max_tokens=5000,
|
|
110
|
+
period=60,
|
|
111
|
+
strategy=Strategy.TOKEN_BUCKET,
|
|
112
|
+
identifier=get_api_key,
|
|
113
|
+
cost=calculate_llm_cost
|
|
114
|
+
)
|
|
115
|
+
async def generate_text(request: Request):
|
|
116
|
+
return {"text": "AI response..."}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 🏭 Production Guide
|
|
122
|
+
|
|
123
|
+
### Using Redis (Recommended for Production)
|
|
124
|
+
|
|
125
|
+
For distributed systems and multi-worker deployments (e.g., Uvicorn/Gunicorn), use the `RedisBackend`. It relies entirely on atomic Lua scripts to prevent race conditions.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from redis.asyncio import Redis
|
|
129
|
+
from metered import Metered, RedisBackend
|
|
130
|
+
|
|
131
|
+
redis_client = Redis.from_url("redis://localhost:6379", decode_responses=True)
|
|
132
|
+
redis_backend = RedisBackend(redis_client)
|
|
133
|
+
|
|
134
|
+
meter = Metered(backend=redis_backend, quota_backend=redis_backend)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Quota Engine & Plans
|
|
138
|
+
Unlike short-lived rate limits, Quotas are persistent billing boundaries (e.g., Monthly API usage).
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# During your app startup phase
|
|
142
|
+
@app.on_event("startup")
|
|
143
|
+
async def startup():
|
|
144
|
+
# Store a persistent quota plan into the backend
|
|
145
|
+
await meter.quotas.set_plan(
|
|
146
|
+
target="user_123",
|
|
147
|
+
plan_name="pro_plan",
|
|
148
|
+
limit=100000, # 100k tokens
|
|
149
|
+
reset_period="monthly", # resets on the 1st of every month
|
|
150
|
+
identifier_type=IdentifierType.USER_ID
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Event Dispatcher (Alerts & Webhooks)
|
|
155
|
+
Metered includes an advanced event dispatcher with two-phase commits, throttling, and a DLQ (Dead Letter Queue) to reliably notify you (e.g. via Slack) when users approach their limits without spamming your network.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# 1. Configure Persistent Outbox
|
|
159
|
+
meter.events.configure(
|
|
160
|
+
backend=redis_backend,
|
|
161
|
+
persist=True,
|
|
162
|
+
cooldown_seconds=3600 # Only alert once per hour per user
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# 2. Start the Background Worker
|
|
166
|
+
asyncio.create_task(meter.events.start_worker())
|
|
167
|
+
|
|
168
|
+
# 3. Listen to Quota Warnings
|
|
169
|
+
@meter.events.on_quota_warning(threshold=0.8)
|
|
170
|
+
async def handle_quota_warning(target: str, plan_name: str, usage_ratio: float, remaining: int):
|
|
171
|
+
# This handler will be retried with exponential backoff if it fails
|
|
172
|
+
await send_slack_alert(f"User {target} is at {usage_ratio*100}% of their {plan_name} plan.")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 📊 Performance & Benchmarks
|
|
178
|
+
|
|
179
|
+
Metered is built to sustain massive concurrency. Below is a benchmark result using `locust` against the `fastapi_saas.py` example (which includes dynamic Redis Quotas, Rate Limits, and Event Dispatching):
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
Type Name # reqs # fails | Avg Min Max Med | req/s failures/s
|
|
183
|
+
--------|---------------------------------------------------------------------------------------------------|--------|-----------
|
|
184
|
+
GET /premium-data 7865 0(0.00%) | 104 2 349 100 | 530.32 0.00
|
|
185
|
+
```
|
|
186
|
+
*(Tested with 100 concurrent headless users. Zero failures or unhandled exceptions).*
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 🌍 Global Middleware
|
|
191
|
+
If you want to apply limits globally rather than per-route, `metered` exports standard middlewares.
|
|
192
|
+
|
|
193
|
+
**FastAPI:**
|
|
194
|
+
```python
|
|
195
|
+
from metered.integrations.fastapi import MeteredMiddleware
|
|
196
|
+
from metered import Limit, Strategy
|
|
197
|
+
|
|
198
|
+
# Limits all traffic globally to 100 req/sec
|
|
199
|
+
app.add_middleware(
|
|
200
|
+
MeteredMiddleware,
|
|
201
|
+
meter=meter,
|
|
202
|
+
limits=[Limit(max_tokens=100, period=1, strategy=Strategy.FIXED_WINDOW)]
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 🤝 Contributing
|
|
209
|
+
Contributions are highly welcomed! Please check our open issues.
|
|
210
|
+
1. Fork the Project
|
|
211
|
+
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
212
|
+
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
213
|
+
4. Run static checks (`mypy metered/`)
|
|
214
|
+
5. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
215
|
+
6. Open a Pull Request
|
|
216
|
+
|
|
217
|
+
## 📄 License
|
|
218
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from locust import HttpUser, task, between
|
|
3
|
+
|
|
4
|
+
# Make sure to run the FastAPI test server before running this benchmark:
|
|
5
|
+
# uvicorn examples.fastapi_saas:app --port 8000
|
|
6
|
+
class MeteredUser(HttpUser):
|
|
7
|
+
# Wait between 10ms and 100ms between tasks to simulate high concurrency
|
|
8
|
+
wait_time = between(0.01, 0.1)
|
|
9
|
+
|
|
10
|
+
@task
|
|
11
|
+
def access_premium_endpoint(self):
|
|
12
|
+
# We simulate passing a mock API key
|
|
13
|
+
headers = {"x-api-key": f"user_bench_{os.getpid()}"}
|
|
14
|
+
|
|
15
|
+
# Test normal endpoint
|
|
16
|
+
with self.client.get("/premium-data", headers=headers, catch_response=True) as response:
|
|
17
|
+
if response.status_code == 200:
|
|
18
|
+
response.success()
|
|
19
|
+
elif response.status_code == 429:
|
|
20
|
+
# 429 is an expected response from rate limiting, we can mark it as success for benchmark purposes
|
|
21
|
+
# or keep it as failure if we want to measure purely allowed throughput.
|
|
22
|
+
response.success()
|
|
23
|
+
else:
|
|
24
|
+
response.failure(f"Unexpected status code: {response.status_code}")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Welcome to Metered
|
|
2
|
+
|
|
3
|
+
**Production-grade rate limiting and quota tracking for Python web frameworks.**
|
|
4
|
+
|
|
5
|
+
`metered` is a modular, high-performance rate limiting library designed to unify short-term API abuse protection and long-term quota tracking (e.g., billing boundaries).
|
|
6
|
+
|
|
7
|
+
## Why Metered?
|
|
8
|
+
|
|
9
|
+
1. **Framework Agnostic:** First-class support for FastAPI and Flask.
|
|
10
|
+
2. **Distributed Ready:** Start with `InMemoryBackend` in dev, scale out with atomic `RedisBackend` in production.
|
|
11
|
+
3. **Flexible Algorithms:** Choose from Token Bucket, Sliding Window, Fixed Window, or Leaky Bucket.
|
|
12
|
+
4. **Event Hooks:** React to quota thresholds instantly.
|
|
13
|
+
|
|
14
|
+
For full usage instructions, check out the API references.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Migrating from fastapi-limiter to metered
|
|
2
|
+
|
|
3
|
+
If you are currently using `fastapi-limiter` for your FastAPI application, upgrading to `metered` provides several advantages:
|
|
4
|
+
- Built-in billing/quota tracking (separated from ephemeral rate limits)
|
|
5
|
+
- Robust timezone-aware quota resets
|
|
6
|
+
- Analytics and usage tracking ready
|
|
7
|
+
- Event hooks (e.g. `on_quota_warning`) for sending webhooks or emails
|
|
8
|
+
|
|
9
|
+
## 1. Initialization
|
|
10
|
+
|
|
11
|
+
**fastapi-limiter:**
|
|
12
|
+
```python
|
|
13
|
+
import redis.asyncio as redis
|
|
14
|
+
from fastapi_limiter import FastAPILimiter
|
|
15
|
+
|
|
16
|
+
@app.on_event("startup")
|
|
17
|
+
async def startup():
|
|
18
|
+
redis_client = redis.from_url("redis://localhost", encoding="utf-8", decode_responses=True)
|
|
19
|
+
await FastAPILimiter.init(redis_client)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**metered:**
|
|
23
|
+
```python
|
|
24
|
+
from metered import Metered, RedisBackend
|
|
25
|
+
from redis.asyncio import Redis
|
|
26
|
+
|
|
27
|
+
redis_client = Redis.from_url("redis://localhost:6379", decode_responses=True)
|
|
28
|
+
meter = Metered(backend=RedisBackend(redis_client))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 2. Route Decorators
|
|
32
|
+
|
|
33
|
+
**fastapi-limiter:**
|
|
34
|
+
```python
|
|
35
|
+
from fastapi_limiter.depends import RateLimiter
|
|
36
|
+
from fastapi import Depends
|
|
37
|
+
|
|
38
|
+
@app.get("/", dependencies=[Depends(RateLimiter(times=2, seconds=5))])
|
|
39
|
+
async def index():
|
|
40
|
+
return {"msg": "Hello World"}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**metered:**
|
|
44
|
+
```python
|
|
45
|
+
from metered import Limit
|
|
46
|
+
|
|
47
|
+
def get_ip(request):
|
|
48
|
+
return request.client.host
|
|
49
|
+
|
|
50
|
+
@app.get("/")
|
|
51
|
+
@meter.apply(Limit(max_tokens=2, period_seconds=5, identifier=get_ip))
|
|
52
|
+
async def index():
|
|
53
|
+
return {"msg": "Hello World"}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 3. Adding Quotas (metered exclusive)
|
|
57
|
+
|
|
58
|
+
Unlike `fastapi-limiter`, `metered` allows you to stack a long-term billing Quota directly on top of your short-term rate limit:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from metered import Quota
|
|
62
|
+
|
|
63
|
+
@app.get("/")
|
|
64
|
+
@meter.apply(
|
|
65
|
+
Limit(max_tokens=2, period_seconds=5, identifier=get_ip),
|
|
66
|
+
Quota(plan_name="premium", identifier=get_ip)
|
|
67
|
+
)
|
|
68
|
+
async def index():
|
|
69
|
+
return {"msg": "Hello World"}
|
|
70
|
+
```
|