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.
Files changed (35) hide show
  1. metered-1.0.0/.github/workflows/ci.yml +42 -0
  2. metered-1.0.0/.gitignore +30 -0
  3. metered-1.0.0/LICENSE +21 -0
  4. metered-1.0.0/PKG-INFO +275 -0
  5. metered-1.0.0/README.md +218 -0
  6. metered-1.0.0/benchmarks/locustfile.py +24 -0
  7. metered-1.0.0/docs/index.md +14 -0
  8. metered-1.0.0/docs/migration.md +70 -0
  9. metered-1.0.0/examples/fastapi_llm_cost.py +37 -0
  10. metered-1.0.0/examples/fastapi_saas.py +48 -0
  11. metered-1.0.0/examples/flask_basic.py +27 -0
  12. metered-1.0.0/examples/multiple_limits.py +22 -0
  13. metered-1.0.0/examples/webhook_event_handler.py +44 -0
  14. metered-1.0.0/metered/__init__.py +17 -0
  15. metered-1.0.0/metered/analytics/__init__.py +4 -0
  16. metered-1.0.0/metered/analytics/models.py +15 -0
  17. metered-1.0.0/metered/analytics/tracker.py +30 -0
  18. metered-1.0.0/metered/backends/memory.py +174 -0
  19. metered-1.0.0/metered/backends/redis.py +249 -0
  20. metered-1.0.0/metered/core/backends.py +19 -0
  21. metered-1.0.0/metered/core/limiter.py +188 -0
  22. metered-1.0.0/metered/core/quota.py +171 -0
  23. metered-1.0.0/metered/core/types.py +48 -0
  24. metered-1.0.0/metered/hooks/events.py +162 -0
  25. metered-1.0.0/metered/integrations/fastapi.py +38 -0
  26. metered-1.0.0/metered/integrations/flask.py +25 -0
  27. metered-1.0.0/mkdocs.yml +10 -0
  28. metered-1.0.0/pyproject.toml +39 -0
  29. metered-1.0.0/tests/integration/test_fastapi.py +32 -0
  30. metered-1.0.0/tests/integration/test_flask.py +33 -0
  31. metered-1.0.0/tests/integration/test_redis_backend.py +22 -0
  32. metered-1.0.0/tests/integration/test_redis_concurrency.py +63 -0
  33. metered-1.0.0/tests/unit/test_memory_algorithms.py +51 -0
  34. metered-1.0.0/tests/unit/test_memory_backend.py +23 -0
  35. 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
@@ -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
+ [![Python Version](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/downloads/)
61
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
62
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](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.
@@ -0,0 +1,218 @@
1
+ # Metered ⏱️
2
+
3
+ [![Python Version](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/downloads/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](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
+ ```