fastapi-sluice 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.
- fastapi_sluice-0.1.0/PKG-INFO +182 -0
- fastapi_sluice-0.1.0/README.md +171 -0
- fastapi_sluice-0.1.0/pyproject.toml +29 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/__init__.py +19 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/algorithms/__init__.py +0 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/algorithms/base.py +43 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/algorithms/fixed_window.py +46 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/algorithms/sliding_window.py +48 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/algorithms/token_bucket.py +59 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/limiter.py +103 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/lua/__init__.py +11 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/lua/fixed_window.lua +31 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/lua/sliding_window.lua +39 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/lua/token_bucket.lua +46 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/middleware.py +32 -0
- fastapi_sluice-0.1.0/src/fastapi_sluice/py.typed +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: fastapi-sluice
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI rate limiter backed by Redis
|
|
5
|
+
Author: Dennis Wainaina
|
|
6
|
+
Author-email: Dennis Wainaina <dennis@byteslab.io>
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: redis>=4.5.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# FastAPI Sluice
|
|
13
|
+
|
|
14
|
+
**Redis-backed, purely async rate limiting for FastAPI.**
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="docs/assets/logo.svg" alt="logo" width="250" height="250">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
[](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml)
|
|
21
|
+
[](https://www.python.org/downloads/)
|
|
22
|
+
[](./LICENSE)
|
|
23
|
+
[](https://astral.sh/ruff)
|
|
24
|
+
[](https://github.com/astral-sh/ty)
|
|
25
|
+
|
|
26
|
+
FastAPI Sluice is designed for modern async FastAPI applications,
|
|
27
|
+
providing production-ready Redis-backed rate limiting with
|
|
28
|
+
interchangeable algorithms and atomic Lua execution.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- ⚡️ **100% async** - built for FastAPI's async request lifecycle.
|
|
33
|
+
- 🌍 **Global or per-route limits** — protect your entire API or individual
|
|
34
|
+
endpoints with the same API.
|
|
35
|
+
- 🔒 **Atomic Redis operations** — Every rate-limiting decision executes
|
|
36
|
+
inside Redis using Lua, guaranteeing atomic updates under concurrent load
|
|
37
|
+
without race conditions.
|
|
38
|
+
- 🔄 **Three Interchangeable algorithms** - Choose the algorithm that best matches
|
|
39
|
+
your traffic profile and resource constraints. Sluice ships out of the box with the
|
|
40
|
+
three widely used rate-limiting algorithms: Fixed window, sliding window log,
|
|
41
|
+
and token bucket, all swappable behind a single `RateLimiter` API.
|
|
42
|
+
- 🧩 **Flexible identity** — Limit by IP, API key, or any other custom
|
|
43
|
+
attribute. You can also rate limit per-route or globally across your entire API.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
| Dependency | Supported Versions |
|
|
48
|
+
|--------------|--------------------|
|
|
49
|
+
| Python | 3.10+ |
|
|
50
|
+
| FastAPI | 0.100+ |
|
|
51
|
+
| Redis server | 7.4+ |
|
|
52
|
+
| redis-py | 4.5+ |
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Using uv (recommended):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv add fastapi-sluice
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Using pip:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install fastapi-sluice
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Using Poetry:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
poetry add fastapi-sluice
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
Start by connecting to a Redis server and creating a `RateLimiter` instance:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from redis.asyncio import Redis
|
|
80
|
+
from fastapi_sluice import RateLimiter, FixedWindow, SlidingWindow, TokenBucket
|
|
81
|
+
|
|
82
|
+
redis = Redis(host="localhost", port=6379)
|
|
83
|
+
limiter = RateLimiter(redis=redis)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Per-route limiting
|
|
87
|
+
|
|
88
|
+
Apply a limit to a specific route by passing `limiter.limit()` as a dependency.
|
|
89
|
+
Each route gets its own counter, keyed by IP address by default.
|
|
90
|
+
You can pass a `scope` string to group requests to use the same counter.
|
|
91
|
+
By default, the request path is used. Use `scope` when you want multiple
|
|
92
|
+
routes or parameterized URLs to share the same counter.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from fastapi import FastAPI, Depends
|
|
96
|
+
from fastapi_sluice import FixedWindow
|
|
97
|
+
|
|
98
|
+
app = FastAPI()
|
|
99
|
+
|
|
100
|
+
@app.get("/items")
|
|
101
|
+
async def get_items(_=Depends(limiter.limit(algorithm=FixedWindow(limit=5, window_seconds=60), scope="items"))):
|
|
102
|
+
return {"items": []}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Global limiting
|
|
106
|
+
|
|
107
|
+
Use `RateLimitMiddleware` to enforce an API-wide cap that applies to every route.
|
|
108
|
+
A single counter per identity is shared across all endpoints:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fastapi_sluice import RateLimitMiddleware, SlidingWindow
|
|
112
|
+
|
|
113
|
+
app.add_middleware(
|
|
114
|
+
RateLimitMiddleware,
|
|
115
|
+
limiter=limiter,
|
|
116
|
+
algorithm=SlidingWindow(limit=500, window_seconds=60),
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Choosing an algorithm
|
|
121
|
+
|
|
122
|
+
Each algorithm suits a different traffic profile:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from fastapi_sluice import FixedWindow, SlidingWindow, TokenBucket
|
|
126
|
+
|
|
127
|
+
# Fixed window — simplest, cheapest. Best for low-stakes limits.
|
|
128
|
+
FixedWindow(limit=100, window_seconds=60)
|
|
129
|
+
|
|
130
|
+
# Sliding window — accurate per-second fairness, no boundary bursts.
|
|
131
|
+
SlidingWindow(limit=100, window_seconds=60)
|
|
132
|
+
|
|
133
|
+
# Token bucket — sustain a rate while allowing controlled bursts.
|
|
134
|
+
TokenBucket(capacity=50, refill_rate=10) # 10 req/s, burst up to 50
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Algorithm | Performance / Memory | Precision & Fairness | Best Used For |
|
|
138
|
+
|---|---|---|---|
|
|
139
|
+
| Fixed Window | `O(1)` time and space | Low — susceptible to boundary bursting | Standard API protection where perfection isn't critical |
|
|
140
|
+
| Sliding Window | `O(N)` space per user, higher memory cost | Highest — accurate across shifting time windows | Critical or expensive endpoints (e.g. AI generation, payment processing) |
|
|
141
|
+
| Token Bucket | `O(1)` time and space | High — allows controlled traffic bursts | General-purpose API protection and microservices |
|
|
142
|
+
|
|
143
|
+
### Rate limit responses
|
|
144
|
+
|
|
145
|
+
When a client exceeds the limit, Sluice returns a `429 Too Many Requests` response with the following headers:
|
|
146
|
+
|
|
147
|
+
| Header | Description |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `Retry-After` | Seconds to wait before retrying |
|
|
150
|
+
| `X-RateLimit-Limit` | Maximum requests allowed in the window |
|
|
151
|
+
| `X-RateLimit-Remaining` | Requests remaining in the current window |
|
|
152
|
+
|
|
153
|
+
Example response:
|
|
154
|
+
|
|
155
|
+
```http
|
|
156
|
+
HTTP/1.1 429 Too Many Requests
|
|
157
|
+
Retry-After: 30
|
|
158
|
+
X-RateLimit-Limit: 100
|
|
159
|
+
X-RateLimit-Remaining: 0
|
|
160
|
+
|
|
161
|
+
{"detail": "Too many requests"}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Custom Client Identifier
|
|
165
|
+
|
|
166
|
+
You can override the default IP-based key with any attribute like API key or
|
|
167
|
+
user ID. The callable must return a **unique string** per identity since this value
|
|
168
|
+
becomes the rate limit bucket key in Redis. Just create a sync or async callable:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from fastapi import Request
|
|
172
|
+
|
|
173
|
+
def get_api_key(request: Request) -> str:
|
|
174
|
+
api_key = request.headers.get("X-API-Key")
|
|
175
|
+
if api_key is None:
|
|
176
|
+
raise ValueError("X-API-Key header is missing")
|
|
177
|
+
return api_key
|
|
178
|
+
|
|
179
|
+
@app.get("/items")
|
|
180
|
+
async def get_items(_=Depends(limiter.limit(algorithm=FixedWindow(limit=30, window_seconds=60), key_func=get_api_key))):
|
|
181
|
+
return {"items": []}
|
|
182
|
+
```
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# FastAPI Sluice
|
|
2
|
+
|
|
3
|
+
**Redis-backed, purely async rate limiting for FastAPI.**
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="docs/assets/logo.svg" alt="logo" width="250" height="250">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
[](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.python.org/downloads/)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
[](https://astral.sh/ruff)
|
|
13
|
+
[](https://github.com/astral-sh/ty)
|
|
14
|
+
|
|
15
|
+
FastAPI Sluice is designed for modern async FastAPI applications,
|
|
16
|
+
providing production-ready Redis-backed rate limiting with
|
|
17
|
+
interchangeable algorithms and atomic Lua execution.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- ⚡️ **100% async** - built for FastAPI's async request lifecycle.
|
|
22
|
+
- 🌍 **Global or per-route limits** — protect your entire API or individual
|
|
23
|
+
endpoints with the same API.
|
|
24
|
+
- 🔒 **Atomic Redis operations** — Every rate-limiting decision executes
|
|
25
|
+
inside Redis using Lua, guaranteeing atomic updates under concurrent load
|
|
26
|
+
without race conditions.
|
|
27
|
+
- 🔄 **Three Interchangeable algorithms** - Choose the algorithm that best matches
|
|
28
|
+
your traffic profile and resource constraints. Sluice ships out of the box with the
|
|
29
|
+
three widely used rate-limiting algorithms: Fixed window, sliding window log,
|
|
30
|
+
and token bucket, all swappable behind a single `RateLimiter` API.
|
|
31
|
+
- 🧩 **Flexible identity** — Limit by IP, API key, or any other custom
|
|
32
|
+
attribute. You can also rate limit per-route or globally across your entire API.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
| Dependency | Supported Versions |
|
|
37
|
+
|--------------|--------------------|
|
|
38
|
+
| Python | 3.10+ |
|
|
39
|
+
| FastAPI | 0.100+ |
|
|
40
|
+
| Redis server | 7.4+ |
|
|
41
|
+
| redis-py | 4.5+ |
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Using uv (recommended):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv add fastapi-sluice
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Using pip:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install fastapi-sluice
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Using Poetry:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
poetry add fastapi-sluice
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
Start by connecting to a Redis server and creating a `RateLimiter` instance:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from redis.asyncio import Redis
|
|
69
|
+
from fastapi_sluice import RateLimiter, FixedWindow, SlidingWindow, TokenBucket
|
|
70
|
+
|
|
71
|
+
redis = Redis(host="localhost", port=6379)
|
|
72
|
+
limiter = RateLimiter(redis=redis)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Per-route limiting
|
|
76
|
+
|
|
77
|
+
Apply a limit to a specific route by passing `limiter.limit()` as a dependency.
|
|
78
|
+
Each route gets its own counter, keyed by IP address by default.
|
|
79
|
+
You can pass a `scope` string to group requests to use the same counter.
|
|
80
|
+
By default, the request path is used. Use `scope` when you want multiple
|
|
81
|
+
routes or parameterized URLs to share the same counter.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from fastapi import FastAPI, Depends
|
|
85
|
+
from fastapi_sluice import FixedWindow
|
|
86
|
+
|
|
87
|
+
app = FastAPI()
|
|
88
|
+
|
|
89
|
+
@app.get("/items")
|
|
90
|
+
async def get_items(_=Depends(limiter.limit(algorithm=FixedWindow(limit=5, window_seconds=60), scope="items"))):
|
|
91
|
+
return {"items": []}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Global limiting
|
|
95
|
+
|
|
96
|
+
Use `RateLimitMiddleware` to enforce an API-wide cap that applies to every route.
|
|
97
|
+
A single counter per identity is shared across all endpoints:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fastapi_sluice import RateLimitMiddleware, SlidingWindow
|
|
101
|
+
|
|
102
|
+
app.add_middleware(
|
|
103
|
+
RateLimitMiddleware,
|
|
104
|
+
limiter=limiter,
|
|
105
|
+
algorithm=SlidingWindow(limit=500, window_seconds=60),
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Choosing an algorithm
|
|
110
|
+
|
|
111
|
+
Each algorithm suits a different traffic profile:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from fastapi_sluice import FixedWindow, SlidingWindow, TokenBucket
|
|
115
|
+
|
|
116
|
+
# Fixed window — simplest, cheapest. Best for low-stakes limits.
|
|
117
|
+
FixedWindow(limit=100, window_seconds=60)
|
|
118
|
+
|
|
119
|
+
# Sliding window — accurate per-second fairness, no boundary bursts.
|
|
120
|
+
SlidingWindow(limit=100, window_seconds=60)
|
|
121
|
+
|
|
122
|
+
# Token bucket — sustain a rate while allowing controlled bursts.
|
|
123
|
+
TokenBucket(capacity=50, refill_rate=10) # 10 req/s, burst up to 50
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Algorithm | Performance / Memory | Precision & Fairness | Best Used For |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| Fixed Window | `O(1)` time and space | Low — susceptible to boundary bursting | Standard API protection where perfection isn't critical |
|
|
129
|
+
| Sliding Window | `O(N)` space per user, higher memory cost | Highest — accurate across shifting time windows | Critical or expensive endpoints (e.g. AI generation, payment processing) |
|
|
130
|
+
| Token Bucket | `O(1)` time and space | High — allows controlled traffic bursts | General-purpose API protection and microservices |
|
|
131
|
+
|
|
132
|
+
### Rate limit responses
|
|
133
|
+
|
|
134
|
+
When a client exceeds the limit, Sluice returns a `429 Too Many Requests` response with the following headers:
|
|
135
|
+
|
|
136
|
+
| Header | Description |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `Retry-After` | Seconds to wait before retrying |
|
|
139
|
+
| `X-RateLimit-Limit` | Maximum requests allowed in the window |
|
|
140
|
+
| `X-RateLimit-Remaining` | Requests remaining in the current window |
|
|
141
|
+
|
|
142
|
+
Example response:
|
|
143
|
+
|
|
144
|
+
```http
|
|
145
|
+
HTTP/1.1 429 Too Many Requests
|
|
146
|
+
Retry-After: 30
|
|
147
|
+
X-RateLimit-Limit: 100
|
|
148
|
+
X-RateLimit-Remaining: 0
|
|
149
|
+
|
|
150
|
+
{"detail": "Too many requests"}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Custom Client Identifier
|
|
154
|
+
|
|
155
|
+
You can override the default IP-based key with any attribute like API key or
|
|
156
|
+
user ID. The callable must return a **unique string** per identity since this value
|
|
157
|
+
becomes the rate limit bucket key in Redis. Just create a sync or async callable:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from fastapi import Request
|
|
161
|
+
|
|
162
|
+
def get_api_key(request: Request) -> str:
|
|
163
|
+
api_key = request.headers.get("X-API-Key")
|
|
164
|
+
if api_key is None:
|
|
165
|
+
raise ValueError("X-API-Key header is missing")
|
|
166
|
+
return api_key
|
|
167
|
+
|
|
168
|
+
@app.get("/items")
|
|
169
|
+
async def get_items(_=Depends(limiter.limit(algorithm=FixedWindow(limit=30, window_seconds=60), key_func=get_api_key))):
|
|
170
|
+
return {"items": []}
|
|
171
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-sluice"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "FastAPI rate limiter backed by Redis"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Dennis Wainaina", email = "dennis@byteslab.io" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.100.0",
|
|
12
|
+
"redis>=4.5.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.8.17,<0.9.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
asyncio_mode = "auto"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"fakeredis[lua]>=2.36.2",
|
|
25
|
+
"pytest>=9.1.1",
|
|
26
|
+
"pytest-asyncio>=1.4.0",
|
|
27
|
+
"ruff>=0.15.18",
|
|
28
|
+
"ty>=0.0.53",
|
|
29
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API lives here so users do:
|
|
3
|
+
from fastapi_sluice import RateLimiter, TokenBucket, SlidingWindow
|
|
4
|
+
instead of reaching into submodules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi_sluice.algorithms.fixed_window import FixedWindow
|
|
8
|
+
from fastapi_sluice.algorithms.sliding_window import SlidingWindow
|
|
9
|
+
from fastapi_sluice.algorithms.token_bucket import TokenBucket
|
|
10
|
+
from fastapi_sluice.limiter import RateLimiter
|
|
11
|
+
from fastapi_sluice.middleware import RateLimitMiddleware
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FixedWindow",
|
|
15
|
+
"RateLimitMiddleware",
|
|
16
|
+
"RateLimiter",
|
|
17
|
+
"SlidingWindow",
|
|
18
|
+
"TokenBucket",
|
|
19
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RateLimitResult:
|
|
9
|
+
"""
|
|
10
|
+
Outcome of a rate-limit check
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
allowed: bool
|
|
14
|
+
limit: int
|
|
15
|
+
remaining: int
|
|
16
|
+
retry_after: int # seconds; 0 if allowed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimitAlgorithm(ABC):
|
|
20
|
+
"""
|
|
21
|
+
Base class for a rate-limiting algorithm
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, limit: int, window_seconds: int) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Args:
|
|
27
|
+
limit: max requests allowed per window e.g. 5 for "5 per second"
|
|
28
|
+
window_seconds: window size in seconds e.g. 1 for "per second"
|
|
29
|
+
"""
|
|
30
|
+
if limit <= 0:
|
|
31
|
+
raise ValueError("limit must be greater than 0")
|
|
32
|
+
if window_seconds <= 0:
|
|
33
|
+
raise ValueError("window_seconds must be greater than 0")
|
|
34
|
+
self.limit = limit
|
|
35
|
+
self.window_seconds = window_seconds
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def check(self, redis: Redis, key: str) -> RateLimitResult:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def peek(self, redis: Redis, key: str) -> RateLimitResult:
|
|
43
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from redis.asyncio import Redis
|
|
4
|
+
|
|
5
|
+
from fastapi_sluice.algorithms.base import RateLimitAlgorithm, RateLimitResult
|
|
6
|
+
from fastapi_sluice.lua import get_script
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FixedWindow(RateLimitAlgorithm):
|
|
10
|
+
def _window_key(self, key: str) -> str:
|
|
11
|
+
bucket = int(time.time() // self.window_seconds)
|
|
12
|
+
return f"{key}:{bucket}"
|
|
13
|
+
|
|
14
|
+
async def check(self, redis: Redis, key: str) -> RateLimitResult:
|
|
15
|
+
window_key = self._window_key(key=key)
|
|
16
|
+
|
|
17
|
+
script = get_script(redis=redis, name="fixed_window")
|
|
18
|
+
allowed, remaining, retry_after = await script(
|
|
19
|
+
keys=[window_key], args=[self.limit, self.window_seconds]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return RateLimitResult(
|
|
23
|
+
allowed=bool(allowed),
|
|
24
|
+
limit=self.limit,
|
|
25
|
+
remaining=int(remaining),
|
|
26
|
+
retry_after=int(retry_after),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def peek(self, redis: Redis, key: str) -> RateLimitResult:
|
|
30
|
+
window_key = self._window_key(key=key)
|
|
31
|
+
|
|
32
|
+
count = int(await redis.get(window_key) or 0)
|
|
33
|
+
remaining = max(0, self.limit - count)
|
|
34
|
+
|
|
35
|
+
retry_after = 0
|
|
36
|
+
if remaining == 0:
|
|
37
|
+
ttl = await redis.ttl(window_key)
|
|
38
|
+
if ttl > 0:
|
|
39
|
+
retry_after = ttl
|
|
40
|
+
|
|
41
|
+
return RateLimitResult(
|
|
42
|
+
allowed=remaining > 0,
|
|
43
|
+
limit=self.limit,
|
|
44
|
+
remaining=remaining,
|
|
45
|
+
retry_after=retry_after,
|
|
46
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
|
|
6
|
+
from fastapi_sluice.algorithms.base import RateLimitAlgorithm, RateLimitResult
|
|
7
|
+
from fastapi_sluice.lua import get_script
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SlidingWindow(RateLimitAlgorithm):
|
|
11
|
+
async def check(self, redis: Redis, key: str) -> RateLimitResult:
|
|
12
|
+
script = get_script(redis=redis, name="sliding_window")
|
|
13
|
+
allowed, remaining, retry_after = await script(
|
|
14
|
+
keys=[key], args=[self.limit, self.window_seconds, int(time.time())]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
return RateLimitResult(
|
|
18
|
+
allowed=bool(allowed),
|
|
19
|
+
limit=self.limit,
|
|
20
|
+
remaining=int(remaining),
|
|
21
|
+
retry_after=int(retry_after),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
async def peek(self, redis: Redis, key: str) -> RateLimitResult:
|
|
25
|
+
now = int(time.time())
|
|
26
|
+
window_start = now - self.window_seconds
|
|
27
|
+
|
|
28
|
+
await redis.zremrangebyscore(key, 0, window_start)
|
|
29
|
+
count = int(await redis.zcard(key) or 0)
|
|
30
|
+
remaining = max(0, self.limit - count)
|
|
31
|
+
|
|
32
|
+
retry_after = 0
|
|
33
|
+
if remaining == 0:
|
|
34
|
+
oldest = await redis.zrange(key, 0, 0, withscores=True)
|
|
35
|
+
if oldest:
|
|
36
|
+
if isinstance(oldest[0][1], (int, float)):
|
|
37
|
+
retry_after = max(
|
|
38
|
+
0, math.ceil((oldest[0][1] + self.window_seconds) - now)
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
retry_after = self.window_seconds
|
|
42
|
+
|
|
43
|
+
return RateLimitResult(
|
|
44
|
+
allowed=remaining > 0,
|
|
45
|
+
limit=self.limit,
|
|
46
|
+
remaining=remaining,
|
|
47
|
+
retry_after=retry_after,
|
|
48
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
|
|
6
|
+
from fastapi_sluice.algorithms.base import RateLimitAlgorithm, RateLimitResult
|
|
7
|
+
from fastapi_sluice.lua import get_script
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenBucket(RateLimitAlgorithm):
|
|
11
|
+
def __init__(self, capacity: int, refill_rate: float) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Args:
|
|
14
|
+
capacity: maximum number of tokens the bucket holds
|
|
15
|
+
refill_rate: tokens added per second
|
|
16
|
+
"""
|
|
17
|
+
if capacity <= 0:
|
|
18
|
+
raise ValueError("capacity must be greater than 0")
|
|
19
|
+
if refill_rate <= 0:
|
|
20
|
+
raise ValueError("refill_rate must be greater than 0")
|
|
21
|
+
self.capacity = capacity
|
|
22
|
+
self.refill_rate = refill_rate
|
|
23
|
+
|
|
24
|
+
async def check(self, redis: Redis, key: str) -> RateLimitResult:
|
|
25
|
+
script = get_script(redis=redis, name="token_bucket")
|
|
26
|
+
allowed, remaining, retry_after = await script(
|
|
27
|
+
keys=[key], args=[self.capacity, self.refill_rate, int(time.time())]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return RateLimitResult(
|
|
31
|
+
allowed=bool(allowed),
|
|
32
|
+
limit=self.capacity,
|
|
33
|
+
remaining=int(remaining),
|
|
34
|
+
retry_after=int(retry_after),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def peek(self, redis: Redis, key: str) -> RateLimitResult:
|
|
38
|
+
now = int(time.time())
|
|
39
|
+
|
|
40
|
+
bucket = await redis.hmget(key, "tokens", "last_refill")
|
|
41
|
+
tokens = float(bucket[0]) if bucket[0] is not None else float(self.capacity)
|
|
42
|
+
last_refill = float(bucket[1]) if bucket[1] is not None else now
|
|
43
|
+
|
|
44
|
+
elapsed = now - last_refill
|
|
45
|
+
tokens = min(tokens + elapsed * self.refill_rate, self.capacity)
|
|
46
|
+
|
|
47
|
+
remaining = int(tokens)
|
|
48
|
+
allowed = remaining >= 1
|
|
49
|
+
|
|
50
|
+
retry_after = 0
|
|
51
|
+
if not allowed:
|
|
52
|
+
retry_after = math.ceil((1 - tokens) / self.refill_rate)
|
|
53
|
+
|
|
54
|
+
return RateLimitResult(
|
|
55
|
+
allowed=allowed,
|
|
56
|
+
limit=self.capacity,
|
|
57
|
+
remaining=remaining,
|
|
58
|
+
retry_after=retry_after,
|
|
59
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Awaitable, Callable
|
|
3
|
+
|
|
4
|
+
from fastapi import HTTPException, Request, status
|
|
5
|
+
from redis.asyncio import Redis
|
|
6
|
+
|
|
7
|
+
from fastapi_sluice.algorithms.base import RateLimitAlgorithm
|
|
8
|
+
|
|
9
|
+
KeyFunc = Callable[[Request], Awaitable[str] | str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _default_key_func(request: Request) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Default identifier: client IP address
|
|
15
|
+
"""
|
|
16
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
17
|
+
if forwarded:
|
|
18
|
+
return forwarded.split(",")[0].strip()
|
|
19
|
+
if request.client:
|
|
20
|
+
return request.client.host
|
|
21
|
+
raise ValueError("Could not determine client identity from request")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimiter:
|
|
25
|
+
def __init__(self, redis: Redis, namespace: str = "sluice"):
|
|
26
|
+
self.redis = redis
|
|
27
|
+
self.namespace = namespace
|
|
28
|
+
|
|
29
|
+
def limit(
|
|
30
|
+
self,
|
|
31
|
+
algorithm: RateLimitAlgorithm,
|
|
32
|
+
scope: str | None = None,
|
|
33
|
+
key_func: KeyFunc = _default_key_func,
|
|
34
|
+
) -> Callable[[Request], Awaitable[None]]:
|
|
35
|
+
"""
|
|
36
|
+
Returns a FastAPI dependency that enforces a per-route rate limit.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
algorithm: the rate limiting algorithm to use e.g. FixedWindow, SlidingWindow, TokenBucket
|
|
40
|
+
scope: optional string to group requests into a shared bucket e.g. "monitors".
|
|
41
|
+
Defaults to the request URL path, giving each unique URL its own counter.
|
|
42
|
+
key_func: callable that extracts a unique identity from the request e.g. IP, user ID.
|
|
43
|
+
Defaults to the client IP address.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def dependency(request: Request) -> None:
|
|
47
|
+
if asyncio.iscoroutinefunction(key_func):
|
|
48
|
+
identity = await key_func(request)
|
|
49
|
+
else:
|
|
50
|
+
identity = key_func(request)
|
|
51
|
+
|
|
52
|
+
route_segment = scope or request.url.path
|
|
53
|
+
redis_key = f"{self.namespace}:{route_segment}:{identity}"
|
|
54
|
+
|
|
55
|
+
result = await algorithm.check(redis=self.redis, key=redis_key)
|
|
56
|
+
|
|
57
|
+
if not result.allowed:
|
|
58
|
+
headers = {
|
|
59
|
+
"Retry-After": str(result.retry_after),
|
|
60
|
+
"X-RateLimit-Limit": str(result.limit),
|
|
61
|
+
"X-RateLimit-Remaining": str(result.remaining),
|
|
62
|
+
}
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
65
|
+
detail="Too many requests",
|
|
66
|
+
headers=headers,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return dependency
|
|
70
|
+
|
|
71
|
+
async def limit_global(
|
|
72
|
+
self,
|
|
73
|
+
request: Request,
|
|
74
|
+
algorithm: RateLimitAlgorithm,
|
|
75
|
+
key_func: KeyFunc = _default_key_func,
|
|
76
|
+
) -> tuple[bool, dict]:
|
|
77
|
+
"""
|
|
78
|
+
Checks a global rate limit shared across all routes. Intended for use in middleware.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
request: the incoming FastAPI/Starlette request
|
|
82
|
+
algorithm: the rate limiting algorithm to use e.g. FixedWindow, SlidingWindow, TokenBucket
|
|
83
|
+
key_func: callable that extracts a unique identity from the request e.g. IP, user ID.
|
|
84
|
+
Defaults to the client IP address.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A tuple of (allowed, headers) where headers contains the X-RateLimit-* and Retry-After values.
|
|
88
|
+
"""
|
|
89
|
+
if asyncio.iscoroutinefunction(key_func):
|
|
90
|
+
identity = await key_func(request)
|
|
91
|
+
else:
|
|
92
|
+
identity = key_func(request)
|
|
93
|
+
|
|
94
|
+
redis_key = f"{self.namespace}:global:{identity}"
|
|
95
|
+
result = await algorithm.check(redis=self.redis, key=redis_key)
|
|
96
|
+
|
|
97
|
+
headers = {
|
|
98
|
+
"Retry-After": str(result.retry_after),
|
|
99
|
+
"X-RateLimit-Limit": str(result.limit),
|
|
100
|
+
"X-RateLimit-Remaining": str(result.remaining),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result.allowed, headers
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from redis.asyncio import Redis
|
|
4
|
+
from redis.commands.core import AsyncScript
|
|
5
|
+
|
|
6
|
+
LUA_DIR = Path(__file__).parent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_script(redis: Redis, name: str) -> AsyncScript:
|
|
10
|
+
script = (LUA_DIR / f"{name}.lua").read_text()
|
|
11
|
+
return redis.register_script(script)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Fixed window: atomic check-and-consume
|
|
2
|
+
-- KEYS[1] = window key
|
|
3
|
+
-- ARGV[1] = limit i.e. max requests per window
|
|
4
|
+
-- ARGV[2] = window size in seconds
|
|
5
|
+
--
|
|
6
|
+
-- Returns: { allowed (0/1), remaining, retry_after_seconds }
|
|
7
|
+
|
|
8
|
+
local limit = tonumber(ARGV[1])
|
|
9
|
+
local window = tonumber(ARGV[2])
|
|
10
|
+
|
|
11
|
+
local count = redis.call("INCR", KEYS[1])
|
|
12
|
+
|
|
13
|
+
if count == 1 then
|
|
14
|
+
redis.call("EXPIRE", KEYS[1], window)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local allowed = 0 -- false
|
|
18
|
+
local retry_after = 0
|
|
19
|
+
|
|
20
|
+
if count <= limit then
|
|
21
|
+
allowed = 1
|
|
22
|
+
else
|
|
23
|
+
retry_after = redis.call("TTL", KEYS[1])
|
|
24
|
+
if retry_after < 0 then
|
|
25
|
+
retry_after = window
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
local remaining = math.max(0, limit - count)
|
|
30
|
+
|
|
31
|
+
return { allowed, remaining, retry_after }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- Sliding window log: atomic check-and-consume
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] = rate limit key
|
|
4
|
+
-- ARGV[1] = limit (max requests per window)
|
|
5
|
+
-- ARGV[2] = window size in seconds
|
|
6
|
+
-- ARGV[3] = now (unix timestamp)
|
|
7
|
+
--
|
|
8
|
+
-- Returns: { allowed (0/1), remaining, retry_after_seconds }
|
|
9
|
+
|
|
10
|
+
local limit = tonumber(ARGV[1])
|
|
11
|
+
local window_seconds = tonumber(ARGV[2])
|
|
12
|
+
local now = tonumber(ARGV[3])
|
|
13
|
+
local window_start = now - window_seconds
|
|
14
|
+
|
|
15
|
+
-- Evict expired entries then count what remains in the window
|
|
16
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, window_start)
|
|
17
|
+
|
|
18
|
+
local count = redis.call("ZCARD", KEYS[1])
|
|
19
|
+
|
|
20
|
+
local allowed = 0
|
|
21
|
+
local retry_after = 0
|
|
22
|
+
local remaining = 0
|
|
23
|
+
|
|
24
|
+
if count < limit then
|
|
25
|
+
allowed = 1
|
|
26
|
+
redis.call("ZADD", KEYS[1], now, tostring(now) .. ":" .. tostring(count))
|
|
27
|
+
redis.call("EXPIRE", KEYS[1], window_seconds)
|
|
28
|
+
|
|
29
|
+
remaining = limit - count - 1
|
|
30
|
+
else
|
|
31
|
+
local oldest = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
|
|
32
|
+
if oldest[2] then
|
|
33
|
+
retry_after = math.ceil((tonumber(oldest[2]) + window_seconds) - now)
|
|
34
|
+
else
|
|
35
|
+
retry_after = window_seconds
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
return { allowed, remaining, retry_after }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- Token Bucket
|
|
2
|
+
--
|
|
3
|
+
-- KEYS[1] = bucket key
|
|
4
|
+
-- ARGV[1] = capacity (maximum number of tokens)
|
|
5
|
+
-- ARGV[2] = refill_rate (tokens per second)
|
|
6
|
+
-- ARGV[3] = now (unix timestamp)
|
|
7
|
+
--
|
|
8
|
+
-- Returns: { allowed (0/1), remaining_tokens, retry_after_seconds }
|
|
9
|
+
|
|
10
|
+
local bucket_key = KEYS[1]
|
|
11
|
+
local capacity = tonumber(ARGV[1])
|
|
12
|
+
local refill_rate = tonumber(ARGV[2])
|
|
13
|
+
local now = tonumber(ARGV[3])
|
|
14
|
+
|
|
15
|
+
local bucket = redis.call("HMGET", bucket_key, "tokens", "last_refill")
|
|
16
|
+
|
|
17
|
+
local tokens = tonumber(bucket[1])
|
|
18
|
+
local last_refill = tonumber(bucket[2])
|
|
19
|
+
|
|
20
|
+
-- first request, bucket starts full
|
|
21
|
+
if tokens == nil or last_refill == nil then
|
|
22
|
+
tokens = capacity
|
|
23
|
+
last_refill = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
-- refill
|
|
27
|
+
local elapsed = now - last_refill
|
|
28
|
+
local refill_tokens = elapsed * refill_rate
|
|
29
|
+
tokens = math.min(tokens + refill_tokens, capacity)
|
|
30
|
+
|
|
31
|
+
local allowed = 0 -- false
|
|
32
|
+
local retry_after = 0
|
|
33
|
+
|
|
34
|
+
if tokens >= 1 then
|
|
35
|
+
allowed = 1
|
|
36
|
+
tokens = tokens - 1
|
|
37
|
+
|
|
38
|
+
redis.call("HSET", bucket_key, "tokens", tokens, "last_refill", now)
|
|
39
|
+
|
|
40
|
+
local ttl = math.ceil(capacity / refill_rate) + 60
|
|
41
|
+
redis.call("EXPIRE", bucket_key, ttl)
|
|
42
|
+
else
|
|
43
|
+
retry_after = math.ceil((1 - tokens) / refill_rate)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return { allowed, math.floor(tokens), retry_after }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2
|
+
from starlette.requests import Request
|
|
3
|
+
from starlette.responses import JSONResponse
|
|
4
|
+
|
|
5
|
+
from fastapi_sluice.algorithms.base import RateLimitAlgorithm
|
|
6
|
+
from fastapi_sluice.limiter import KeyFunc, RateLimiter, _default_key_func
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
app,
|
|
13
|
+
limiter: RateLimiter,
|
|
14
|
+
algorithm: RateLimitAlgorithm,
|
|
15
|
+
key_func: KeyFunc = _default_key_func,
|
|
16
|
+
):
|
|
17
|
+
super().__init__(app)
|
|
18
|
+
self.limiter = limiter
|
|
19
|
+
self.algorithm = algorithm
|
|
20
|
+
self.key_func = key_func
|
|
21
|
+
|
|
22
|
+
async def dispatch(self, request: Request, call_next):
|
|
23
|
+
allowed, headers = await self.limiter.limit_global(
|
|
24
|
+
request, self.algorithm, self.key_func
|
|
25
|
+
)
|
|
26
|
+
if not allowed:
|
|
27
|
+
return JSONResponse(
|
|
28
|
+
status_code=429,
|
|
29
|
+
content={"detail": "Too many requests"},
|
|
30
|
+
headers=headers,
|
|
31
|
+
)
|
|
32
|
+
return await call_next(request)
|
|
File without changes
|