sluice-limiter 0.1.1__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.
- sluice_limiter-0.1.1/LICENSE +21 -0
- sluice_limiter-0.1.1/PKG-INFO +165 -0
- sluice_limiter-0.1.1/README.md +129 -0
- sluice_limiter-0.1.1/pyproject.toml +66 -0
- sluice_limiter-0.1.1/setup.cfg +4 -0
- sluice_limiter-0.1.1/sluice/__init__.py +21 -0
- sluice_limiter-0.1.1/sluice/algorithms/__init__.py +14 -0
- sluice_limiter-0.1.1/sluice/algorithms/base.py +64 -0
- sluice_limiter-0.1.1/sluice/algorithms/sliding_window_counter.py +83 -0
- sluice_limiter-0.1.1/sluice/algorithms/sliding_window_log.py +84 -0
- sluice_limiter-0.1.1/sluice/algorithms/token_bucket.py +81 -0
- sluice_limiter-0.1.1/sluice/backends/__init__.py +15 -0
- sluice_limiter-0.1.1/sluice/backends/base.py +40 -0
- sluice_limiter-0.1.1/sluice/backends/fallback_backend.py +212 -0
- sluice_limiter-0.1.1/sluice/backends/hybrid_backend.py +271 -0
- sluice_limiter-0.1.1/sluice/backends/redis_backend.py +114 -0
- sluice_limiter-0.1.1/sluice/middleware/__init__.py +22 -0
- sluice_limiter-0.1.1/sluice/middleware/fastapi.py +122 -0
- sluice_limiter-0.1.1/sluice/middleware/policy.py +190 -0
- sluice_limiter-0.1.1/sluice/middleware/utils.py +170 -0
- sluice_limiter-0.1.1/sluice/scripts/__init__.py +0 -0
- sluice_limiter-0.1.1/sluice_limiter.egg-info/PKG-INFO +165 -0
- sluice_limiter-0.1.1/sluice_limiter.egg-info/SOURCES.txt +30 -0
- sluice_limiter-0.1.1/sluice_limiter.egg-info/dependency_links.txt +1 -0
- sluice_limiter-0.1.1/sluice_limiter.egg-info/requires.txt +16 -0
- sluice_limiter-0.1.1/sluice_limiter.egg-info/top_level.txt +1 -0
- sluice_limiter-0.1.1/tests/test_algorithms.py +242 -0
- sluice_limiter-0.1.1/tests/test_circuit_breaker.py +261 -0
- sluice_limiter-0.1.1/tests/test_concurrency.py +117 -0
- sluice_limiter-0.1.1/tests/test_fallback.py +238 -0
- sluice_limiter-0.1.1/tests/test_hybrid.py +265 -0
- sluice_limiter-0.1.1/tests/test_policy.py +207 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Souvik Sarkar
|
|
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.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sluice-limiter
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Distributed rate limiter: token bucket, sliding window log, sliding window counter — Redis-backed, Lua-atomic, FastAPI middleware.
|
|
5
|
+
Author-email: Souvik Sarkar <souviksarkar2k3@gmail.com>
|
|
6
|
+
Keywords: rate-limiting,redis,fastapi,distributed,lua
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Framework :: FastAPI
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: redis[hiredis]>=6.2
|
|
22
|
+
Requires-Dist: cachetools>=7.1.4
|
|
23
|
+
Requires-Dist: fastapi>=0.116
|
|
24
|
+
Requires-Dist: starlette>=0.47
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.4; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=1.1; extra == "dev"
|
|
28
|
+
Requires-Dist: fakeredis[aioredis,lua]>=2.31; extra == "dev"
|
|
29
|
+
Requires-Dist: httpx2>=2.9; extra == "dev"
|
|
30
|
+
Requires-Dist: uvicorn[standard]>=0.35; extra == "dev"
|
|
31
|
+
Provides-Extra: bench
|
|
32
|
+
Requires-Dist: numpy>=2.3; extra == "bench"
|
|
33
|
+
Requires-Dist: matplotlib>=3.10; extra == "bench"
|
|
34
|
+
Requires-Dist: fakeredis[aioredis,lua]>=2.31; extra == "bench"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# sluice
|
|
38
|
+
|
|
39
|
+
Distributed rate limiter for Python: Token Bucket, Sliding Window Log, and Sliding Window Counter — Redis-backed, Lua-atomic, with a FastAPI/Starlette middleware and a circuit-breaker fallback for Redis outages.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Three algorithms**, each with a documented memory/accuracy/clock-skew trade-off — pick per-route based on what the endpoint actually needs
|
|
44
|
+
- **Atomic by construction** — every check runs as a single Lua script (`EVALSHA`, cached via `SCRIPT LOAD` at connect time) so there's no check-then-act race between concurrent requests
|
|
45
|
+
- **Redis-authoritative time** — scripts use the Redis server clock (`TIME`) instead of client clocks, so limiter correctness doesn't depend on NTP sync across app nodes
|
|
46
|
+
- **Circuit breaker with local fallback** — a three-state breaker (`CLOSED` / `OPEN` / `HALF_OPEN`) that, on sustained Redis failures, degrades to a bounded, self-expiring in-process limiter (`TTLCache`) instead of failing open or failing closed
|
|
47
|
+
- **Declarative FastAPI middleware** — a fluent `RateLimitPolicy` builder maps routes to algorithms; per-route Redis keys are auto-namespaced so two routes never share a counter by accident
|
|
48
|
+
- **Pluggable backend** — `RateLimitBackend` is a `Protocol` (`now_ms` + `evalsha`); `RedisBackend`, `FallbackBackend`, and `HybridBackend` all implement it interchangeably
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install sluice
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python 3.11+.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from fastapi import FastAPI
|
|
62
|
+
from sluice import RateLimitMiddleware, RateLimitPolicy
|
|
63
|
+
from sluice.backends import RedisBackend
|
|
64
|
+
from sluice.algorithms import SlidingWindowCounter, SlidingWindowLog, TokenBucket
|
|
65
|
+
|
|
66
|
+
backend = RedisBackend.from_url("redis://localhost:6379/0")
|
|
67
|
+
|
|
68
|
+
policy = (
|
|
69
|
+
RateLimitPolicy(backend)
|
|
70
|
+
# Auth: exact counting — a ±5% error on a brute-force target is a
|
|
71
|
+
# security issue, not a trade-off.
|
|
72
|
+
.limit("/api/login", SlidingWindowLog, limit=5, window="1m")
|
|
73
|
+
# Expensive endpoint: approximate is fine, memory matters at scale.
|
|
74
|
+
.limit("/api/report", SlidingWindowCounter, limit=10, window="1m")
|
|
75
|
+
# Everything else: token bucket for smooth throughput, allows bursts.
|
|
76
|
+
.default(TokenBucket, capacity=100, window="1m")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
app = FastAPI()
|
|
80
|
+
|
|
81
|
+
@app.on_event("startup")
|
|
82
|
+
async def startup():
|
|
83
|
+
await backend.connect()
|
|
84
|
+
|
|
85
|
+
app.add_middleware(RateLimitMiddleware, resolver=policy.resolver())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Rejected requests get a `429` with `RateLimit-*` headers and a `Retry-After`; allowed requests get the same headers attached so clients can self-throttle proactively. See `examples/basic_app.py` for a complete app with lifespan-managed startup/shutdown.
|
|
89
|
+
|
|
90
|
+
## Algorithms
|
|
91
|
+
|
|
92
|
+
| Algorithm | Memory | Accuracy | Clock-skew sensitivity | Best for |
|
|
93
|
+
|---|---|---|---|---|
|
|
94
|
+
| Token Bucket | O(1) — 2 fields/key | Allows controlled bursts | Benign (skew shifts refill slightly, self-corrects) | General-purpose APIs, smooth throughput |
|
|
95
|
+
| Sliding Window Log | O(N) — 1 entry per request in-window | Exact | Sensitive — skew delays pruning, slightly under-counts | Security-sensitive limits (login, payment) where false positives are costly |
|
|
96
|
+
| Sliding Window Counter | O(1) — 2 counters/key | Approximate, bounded error (~<5% typical) | Moderate — divergence > `window_ms` between nodes can create phantom capacity | High-traffic endpoints where memory matters more than exactness |
|
|
97
|
+
|
|
98
|
+
All three are exposed as async classes (`TokenBucket`, `SlidingWindowLog`, `SlidingWindowCounter`) sharing one interface:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
result = await limiter.check(identifier, cost=1)
|
|
102
|
+
# result.allowed, result.remaining, result.reset_after_ms, result.retry_after_ms
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`identifier` scopes the counter (typically `f"{ip}:{path}"`); `cost` lets a single call consume more than one unit (native support in Token Bucket, sequential admission for the window-based algorithms).
|
|
106
|
+
|
|
107
|
+
## Handling Redis outages
|
|
108
|
+
|
|
109
|
+
A naive rate limiter has two bad options when Redis goes down: fail open (let everything through, right when abuse is most likely) or fail closed (take the service down). `HybridBackend` wraps `RedisBackend` in a circuit breaker that instead falls back to a local, per-node in-memory limiter:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from sluice.backends import HybridBackend
|
|
113
|
+
|
|
114
|
+
backend = HybridBackend(
|
|
115
|
+
redis_url="redis://localhost:6379/0",
|
|
116
|
+
failure_threshold=5, # consecutive failures before opening the circuit
|
|
117
|
+
recovery_timeout_sec=30.0, # base backoff before probing Redis again
|
|
118
|
+
redis_call_timeout_sec=0.5,
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
While the circuit is `OPEN`, each node enforces limits independently — the effective cluster-wide limit becomes `N × configured_limit` for `N` running instances — which is a documented, deliberate trade-off rather than an oversight: legitimate traffic keeps flowing and each node still bounds abuse, and it self-corrects the moment Redis recovers (via a single `HALF_OPEN` probe, jittered so instances don't all re-probe in lockstep).
|
|
123
|
+
|
|
124
|
+
## Declarative policies
|
|
125
|
+
|
|
126
|
+
`RateLimitPolicy` keeps all rate-limit configuration in one place instead of scattering decorators across route handlers, and auto-namespaces Redis keys per route so two `TokenBucket` rules never collide on the same default key prefix:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
policy = (
|
|
130
|
+
RateLimitPolicy(backend)
|
|
131
|
+
.limit("/api/upload", TokenBucket, capacity=10, rate="5/min")
|
|
132
|
+
.default(TokenBucket, capacity=100, window="1m")
|
|
133
|
+
.identifier(jwt_subject_identifier) # rate-limit per authenticated user instead of per IP
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Built-in identifier functions: `ip_identifier`, `ip_route_identifier` (default), `jwt_subject_identifier`.
|
|
138
|
+
|
|
139
|
+
## Project layout
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
sluice/
|
|
143
|
+
├── algorithms/ token_bucket.py, sliding_window_log.py, sliding_window_counter.py
|
|
144
|
+
├── backends/ redis_backend.py, fallback_backend.py, hybrid_backend.py
|
|
145
|
+
├── middleware/ fastapi.py, policy.py, utils.py
|
|
146
|
+
└── scripts/ token_bucket.lua, sliding_window_log.lua, sliding_window_counter.lua
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- `docs/design/` — write-ups on the atomic-Lua approach, the Redis-fallback design, and declarative rate limiting
|
|
150
|
+
- `docs/tradeoffs/` — algorithm selection guide and a deep dive on clock skew
|
|
151
|
+
- `benchmarks/` — traffic-pattern simulations (uniform, burst, window-boundary hammer, Poisson) comparing all three algorithms; run with `python -m benchmarks.main`
|
|
152
|
+
- `tests/` — algorithm correctness, circuit breaker state transitions, fallback behavior, hybrid backend integration, concurrency, and policy building
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install -e ".[dev]"
|
|
158
|
+
pytest
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Tests run against `fakeredis` by default, so no live Redis instance is required.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# sluice
|
|
2
|
+
|
|
3
|
+
Distributed rate limiter for Python: Token Bucket, Sliding Window Log, and Sliding Window Counter — Redis-backed, Lua-atomic, with a FastAPI/Starlette middleware and a circuit-breaker fallback for Redis outages.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Three algorithms**, each with a documented memory/accuracy/clock-skew trade-off — pick per-route based on what the endpoint actually needs
|
|
8
|
+
- **Atomic by construction** — every check runs as a single Lua script (`EVALSHA`, cached via `SCRIPT LOAD` at connect time) so there's no check-then-act race between concurrent requests
|
|
9
|
+
- **Redis-authoritative time** — scripts use the Redis server clock (`TIME`) instead of client clocks, so limiter correctness doesn't depend on NTP sync across app nodes
|
|
10
|
+
- **Circuit breaker with local fallback** — a three-state breaker (`CLOSED` / `OPEN` / `HALF_OPEN`) that, on sustained Redis failures, degrades to a bounded, self-expiring in-process limiter (`TTLCache`) instead of failing open or failing closed
|
|
11
|
+
- **Declarative FastAPI middleware** — a fluent `RateLimitPolicy` builder maps routes to algorithms; per-route Redis keys are auto-namespaced so two routes never share a counter by accident
|
|
12
|
+
- **Pluggable backend** — `RateLimitBackend` is a `Protocol` (`now_ms` + `evalsha`); `RedisBackend`, `FallbackBackend`, and `HybridBackend` all implement it interchangeably
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install sluice
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires Python 3.11+.
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from sluice import RateLimitMiddleware, RateLimitPolicy
|
|
27
|
+
from sluice.backends import RedisBackend
|
|
28
|
+
from sluice.algorithms import SlidingWindowCounter, SlidingWindowLog, TokenBucket
|
|
29
|
+
|
|
30
|
+
backend = RedisBackend.from_url("redis://localhost:6379/0")
|
|
31
|
+
|
|
32
|
+
policy = (
|
|
33
|
+
RateLimitPolicy(backend)
|
|
34
|
+
# Auth: exact counting — a ±5% error on a brute-force target is a
|
|
35
|
+
# security issue, not a trade-off.
|
|
36
|
+
.limit("/api/login", SlidingWindowLog, limit=5, window="1m")
|
|
37
|
+
# Expensive endpoint: approximate is fine, memory matters at scale.
|
|
38
|
+
.limit("/api/report", SlidingWindowCounter, limit=10, window="1m")
|
|
39
|
+
# Everything else: token bucket for smooth throughput, allows bursts.
|
|
40
|
+
.default(TokenBucket, capacity=100, window="1m")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app = FastAPI()
|
|
44
|
+
|
|
45
|
+
@app.on_event("startup")
|
|
46
|
+
async def startup():
|
|
47
|
+
await backend.connect()
|
|
48
|
+
|
|
49
|
+
app.add_middleware(RateLimitMiddleware, resolver=policy.resolver())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Rejected requests get a `429` with `RateLimit-*` headers and a `Retry-After`; allowed requests get the same headers attached so clients can self-throttle proactively. See `examples/basic_app.py` for a complete app with lifespan-managed startup/shutdown.
|
|
53
|
+
|
|
54
|
+
## Algorithms
|
|
55
|
+
|
|
56
|
+
| Algorithm | Memory | Accuracy | Clock-skew sensitivity | Best for |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| Token Bucket | O(1) — 2 fields/key | Allows controlled bursts | Benign (skew shifts refill slightly, self-corrects) | General-purpose APIs, smooth throughput |
|
|
59
|
+
| Sliding Window Log | O(N) — 1 entry per request in-window | Exact | Sensitive — skew delays pruning, slightly under-counts | Security-sensitive limits (login, payment) where false positives are costly |
|
|
60
|
+
| Sliding Window Counter | O(1) — 2 counters/key | Approximate, bounded error (~<5% typical) | Moderate — divergence > `window_ms` between nodes can create phantom capacity | High-traffic endpoints where memory matters more than exactness |
|
|
61
|
+
|
|
62
|
+
All three are exposed as async classes (`TokenBucket`, `SlidingWindowLog`, `SlidingWindowCounter`) sharing one interface:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
result = await limiter.check(identifier, cost=1)
|
|
66
|
+
# result.allowed, result.remaining, result.reset_after_ms, result.retry_after_ms
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`identifier` scopes the counter (typically `f"{ip}:{path}"`); `cost` lets a single call consume more than one unit (native support in Token Bucket, sequential admission for the window-based algorithms).
|
|
70
|
+
|
|
71
|
+
## Handling Redis outages
|
|
72
|
+
|
|
73
|
+
A naive rate limiter has two bad options when Redis goes down: fail open (let everything through, right when abuse is most likely) or fail closed (take the service down). `HybridBackend` wraps `RedisBackend` in a circuit breaker that instead falls back to a local, per-node in-memory limiter:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from sluice.backends import HybridBackend
|
|
77
|
+
|
|
78
|
+
backend = HybridBackend(
|
|
79
|
+
redis_url="redis://localhost:6379/0",
|
|
80
|
+
failure_threshold=5, # consecutive failures before opening the circuit
|
|
81
|
+
recovery_timeout_sec=30.0, # base backoff before probing Redis again
|
|
82
|
+
redis_call_timeout_sec=0.5,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
While the circuit is `OPEN`, each node enforces limits independently — the effective cluster-wide limit becomes `N × configured_limit` for `N` running instances — which is a documented, deliberate trade-off rather than an oversight: legitimate traffic keeps flowing and each node still bounds abuse, and it self-corrects the moment Redis recovers (via a single `HALF_OPEN` probe, jittered so instances don't all re-probe in lockstep).
|
|
87
|
+
|
|
88
|
+
## Declarative policies
|
|
89
|
+
|
|
90
|
+
`RateLimitPolicy` keeps all rate-limit configuration in one place instead of scattering decorators across route handlers, and auto-namespaces Redis keys per route so two `TokenBucket` rules never collide on the same default key prefix:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
policy = (
|
|
94
|
+
RateLimitPolicy(backend)
|
|
95
|
+
.limit("/api/upload", TokenBucket, capacity=10, rate="5/min")
|
|
96
|
+
.default(TokenBucket, capacity=100, window="1m")
|
|
97
|
+
.identifier(jwt_subject_identifier) # rate-limit per authenticated user instead of per IP
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Built-in identifier functions: `ip_identifier`, `ip_route_identifier` (default), `jwt_subject_identifier`.
|
|
102
|
+
|
|
103
|
+
## Project layout
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
sluice/
|
|
107
|
+
├── algorithms/ token_bucket.py, sliding_window_log.py, sliding_window_counter.py
|
|
108
|
+
├── backends/ redis_backend.py, fallback_backend.py, hybrid_backend.py
|
|
109
|
+
├── middleware/ fastapi.py, policy.py, utils.py
|
|
110
|
+
└── scripts/ token_bucket.lua, sliding_window_log.lua, sliding_window_counter.lua
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- `docs/design/` — write-ups on the atomic-Lua approach, the Redis-fallback design, and declarative rate limiting
|
|
114
|
+
- `docs/tradeoffs/` — algorithm selection guide and a deep dive on clock skew
|
|
115
|
+
- `benchmarks/` — traffic-pattern simulations (uniform, burst, window-boundary hammer, Poisson) comparing all three algorithms; run with `python -m benchmarks.main`
|
|
116
|
+
- `tests/` — algorithm correctness, circuit breaker state transitions, fallback behavior, hybrid backend integration, concurrency, and policy building
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[dev]"
|
|
122
|
+
pytest
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Tests run against `fakeredis` by default, so no live Redis instance is required.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sluice-limiter"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Distributed rate limiter: token bucket, sliding window log, sliding window counter — Redis-backed, Lua-atomic, FastAPI middleware."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Souvik Sarkar", email = "souviksarkar2k3@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
keywords = [
|
|
17
|
+
"rate-limiting",
|
|
18
|
+
"redis",
|
|
19
|
+
"fastapi",
|
|
20
|
+
"distributed",
|
|
21
|
+
"lua",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Framework :: FastAPI",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
34
|
+
"Topic :: Software Development :: Libraries",
|
|
35
|
+
"Typing :: Typed",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
dependencies = [
|
|
39
|
+
"redis[hiredis]>=6.2",
|
|
40
|
+
"cachetools>=7.1.4",
|
|
41
|
+
"fastapi>=0.116",
|
|
42
|
+
"starlette>=0.47",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.4",
|
|
48
|
+
"pytest-asyncio>=1.1",
|
|
49
|
+
"fakeredis[aioredis,lua]>=2.31",
|
|
50
|
+
"httpx2>=2.9",
|
|
51
|
+
"uvicorn[standard]>=0.35",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
bench = [
|
|
55
|
+
"numpy>=2.3",
|
|
56
|
+
"matplotlib>=3.10",
|
|
57
|
+
"fakeredis[aioredis,lua]>=2.31",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.setuptools.packages.find]
|
|
61
|
+
where = ["."]
|
|
62
|
+
include = ["sluice*"]
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sluice - Distributed Rate Limiter Library
|
|
3
|
+
=========================================
|
|
4
|
+
|
|
5
|
+
Three algorithms, one Redis backend, pluggable FastAPI middleware.
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sluice.algorithms import RateLimiter, RateLimitResult
|
|
10
|
+
from sluice.backends import RateLimitBackend
|
|
11
|
+
from sluice.middleware import RateLimitMiddleware, RateLimitPolicy
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RateLimiter",
|
|
15
|
+
"RateLimitResult",
|
|
16
|
+
"RateLimitBackend",
|
|
17
|
+
"RateLimitMiddleware",
|
|
18
|
+
"RateLimitPolicy",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from sluice.algorithms.base import RateLimiter, RateLimitResult
|
|
2
|
+
from sluice.algorithms.token_bucket import TokenBucket
|
|
3
|
+
from sluice.algorithms.sliding_window_log import SlidingWindowLog
|
|
4
|
+
from sluice.algorithms.sliding_window_counter import SlidingWindowCounter
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"RateLimiter",
|
|
8
|
+
"RateLimitResult",
|
|
9
|
+
"TokenBucket",
|
|
10
|
+
"SlidingWindowLog",
|
|
11
|
+
"SlidingWindowCounter",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base types shared by all algorithms.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from sluice.backends.base import RateLimitBackend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class RateLimitResult:
|
|
15
|
+
"""
|
|
16
|
+
Returned by every limiter.check().
|
|
17
|
+
|
|
18
|
+
Fields
|
|
19
|
+
------
|
|
20
|
+
allowed : bool - whether the request should proceed
|
|
21
|
+
remaining : int - estimated remaining quota in current window/bucket
|
|
22
|
+
reset_after_ms : int - ms until quota fully resets (algorithm-specific meaning)
|
|
23
|
+
retry_after_ms : int - ms to wait before retrying a rejected request (0 if allowed)
|
|
24
|
+
algorithm : str - which algorithm produced this result
|
|
25
|
+
key : str - the effective Redis key that was evaluated
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
allowed: bool
|
|
29
|
+
remaining: int
|
|
30
|
+
reset_after_ms: int
|
|
31
|
+
retry_after_ms: int
|
|
32
|
+
algorithm: str
|
|
33
|
+
key: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimiter(Protocol):
|
|
37
|
+
"""
|
|
38
|
+
Protocol every algorithm must satisfy.
|
|
39
|
+
Implementations are fully async; call `await backend.connect()` before use.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
backend: RateLimitBackend
|
|
43
|
+
key_prefix: str = "rl"
|
|
44
|
+
algorithm: str
|
|
45
|
+
|
|
46
|
+
async def check(
|
|
47
|
+
self,
|
|
48
|
+
identifier: str,
|
|
49
|
+
cost: int = 1,
|
|
50
|
+
) -> RateLimitResult:
|
|
51
|
+
"""
|
|
52
|
+
Evaluate the rate limit for *identifier*.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
identifier
|
|
57
|
+
Opaque string that scopes the counter — typically ``f"{route}:{client_ip}"``.
|
|
58
|
+
cost
|
|
59
|
+
How many tokens / requests to consume (default 1).
|
|
60
|
+
Token bucket supports cost > 1 natively; window-based algorithms
|
|
61
|
+
treat cost > 1 as *cost* sequential single-unit requests and
|
|
62
|
+
only allow them all if quota permits.
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sliding Window Counter rate limiter.
|
|
3
|
+
|
|
4
|
+
Characteristics
|
|
5
|
+
---------------
|
|
6
|
+
* **Approximate** sliding window via weighted interpolation of two fixed buckets.
|
|
7
|
+
* Memory: O(1) per key — only two integer counters regardless of request volume.
|
|
8
|
+
* The approximation error is bounded: in the worst case the effective count
|
|
9
|
+
can be off by at most `limit * (1 - overlap)` requests at the bucket boundary.
|
|
10
|
+
In practice the error averages < 5% of the limit.
|
|
11
|
+
* Clock-skew effect: **moderate**. Skew shifts which bucket a node writes to.
|
|
12
|
+
If two nodes diverge by more than `window_ms`, they write to different buckets,
|
|
13
|
+
creating phantom capacity. Recommended: keep NTP sync within 100ms.
|
|
14
|
+
|
|
15
|
+
Memory comparison (concrete numbers at 1 000 req/min limit)
|
|
16
|
+
-------------------------------------------------------------
|
|
17
|
+
Token bucket log : 2 fields * 8 bytes ≈ 16 bytes / key
|
|
18
|
+
Sliding window counter: 2 counters * 8 bytes ≈ 16 bytes / key ← this
|
|
19
|
+
Sliding window log : up to 1 000 members * ~64 bytes ≈ 64 KB / key
|
|
20
|
+
|
|
21
|
+
Comparison edge
|
|
22
|
+
---------------
|
|
23
|
+
Best memory-to-accuracy trade-off at high traffic volumes. Preferred default
|
|
24
|
+
for most web APIs where a ±5% error on the rate limit is acceptable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from sluice.backends import RateLimitBackend
|
|
32
|
+
from sluice.algorithms.base import RateLimitResult
|
|
33
|
+
|
|
34
|
+
ALGORITHM = "sliding_window_counter"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SlidingWindowCounter:
|
|
39
|
+
"""
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
backend : RateLimitBackend
|
|
43
|
+
limit : int - max (approximate) requests per `window_ms`
|
|
44
|
+
window_ms : int - window duration in milliseconds
|
|
45
|
+
key_prefix : str - Redis key namespace (default "rl:swc")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
backend: RateLimitBackend
|
|
49
|
+
limit: int
|
|
50
|
+
window_ms: int
|
|
51
|
+
key_prefix: str = "rl:swc"
|
|
52
|
+
|
|
53
|
+
algorithm: str = ALGORITHM
|
|
54
|
+
|
|
55
|
+
async def check(
|
|
56
|
+
self,
|
|
57
|
+
identifier: str,
|
|
58
|
+
cost: int = 1,
|
|
59
|
+
) -> RateLimitResult:
|
|
60
|
+
key = f"{self.key_prefix}:{identifier}"
|
|
61
|
+
now = await self.backend.now_ms()
|
|
62
|
+
|
|
63
|
+
last_result: RateLimitResult | None = None
|
|
64
|
+
|
|
65
|
+
for _ in range(cost):
|
|
66
|
+
result_raw = await self.backend.evalsha(
|
|
67
|
+
"sliding_window_counter",
|
|
68
|
+
keys=[key],
|
|
69
|
+
args=[self.limit, self.window_ms, now],
|
|
70
|
+
)
|
|
71
|
+
last_result = RateLimitResult(
|
|
72
|
+
allowed=bool(int(result_raw[0])),
|
|
73
|
+
remaining=int(result_raw[1]),
|
|
74
|
+
reset_after_ms=int(result_raw[2]),
|
|
75
|
+
retry_after_ms=int(result_raw[3]),
|
|
76
|
+
algorithm=ALGORITHM,
|
|
77
|
+
key=key,
|
|
78
|
+
)
|
|
79
|
+
if not last_result.allowed:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
assert last_result is not None
|
|
83
|
+
return last_result
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sliding Window Log rate limiter.
|
|
3
|
+
|
|
4
|
+
Characteristics
|
|
5
|
+
---------------
|
|
6
|
+
* **Exact** rate limiting: every decision is computed against the real history
|
|
7
|
+
of requests in [now - window, now].
|
|
8
|
+
* Memory: O(N) per key where N = requests in the current window.
|
|
9
|
+
At 1 000 req/s over a 60 s window that's ~60 000 sorted-set members per key.
|
|
10
|
+
Each member costs roughly 64 bytes → ~3.8 MB per high-traffic key.
|
|
11
|
+
* Clock-skew effect: **sensitive**. If two nodes disagree on `now` by Δms,
|
|
12
|
+
entries that should be pruned survive Δms too long, causing slight
|
|
13
|
+
under-counting of capacity. Mitigate by using Redis server time
|
|
14
|
+
as the authoritative clock
|
|
15
|
+
|
|
16
|
+
Comparison edge
|
|
17
|
+
---------------
|
|
18
|
+
The only algorithm that gives a provably exact sliding count.
|
|
19
|
+
Use it when the cost of a false-positive rejection exceeds the memory overhead
|
|
20
|
+
(e.g. payment APIs, quota-sensitive B2B integrations).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from sluice.backends.base import RateLimitBackend
|
|
28
|
+
from sluice.algorithms.base import RateLimitResult
|
|
29
|
+
|
|
30
|
+
ALGORITHM = "sliding_window_log"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SlidingWindowLog:
|
|
35
|
+
"""
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
backend : RateLimitBackend
|
|
39
|
+
limit : int - max requests per `window_ms`
|
|
40
|
+
window_ms : int - window duration in milliseconds
|
|
41
|
+
key_prefix : str - Redis key namespace (default "rl:swl")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
backend: RateLimitBackend
|
|
45
|
+
limit: int
|
|
46
|
+
window_ms: int
|
|
47
|
+
key_prefix: str = "rl:swl"
|
|
48
|
+
|
|
49
|
+
algorithm: str = ALGORITHM
|
|
50
|
+
|
|
51
|
+
async def check(
|
|
52
|
+
self,
|
|
53
|
+
identifier: str,
|
|
54
|
+
cost: int = 1,
|
|
55
|
+
) -> RateLimitResult:
|
|
56
|
+
key = f"{self.key_prefix}:{identifier}"
|
|
57
|
+
now = await self.backend.now_ms()
|
|
58
|
+
|
|
59
|
+
# For cost > 1 we check if *all* cost units can be admitted atomically.
|
|
60
|
+
# The Lua script admits exactly one unit; we call it `cost` times only
|
|
61
|
+
# if the first call succeeds — this keeps the log exact.
|
|
62
|
+
# A single-pass cost > 1 variant would require a heavier Lua script;
|
|
63
|
+
# the trade-off is documented intentionally.
|
|
64
|
+
last_result: RateLimitResult | None = None
|
|
65
|
+
|
|
66
|
+
for i in range(cost):
|
|
67
|
+
result_raw = await self.backend.evalsha(
|
|
68
|
+
"sliding_window_log",
|
|
69
|
+
keys=[key],
|
|
70
|
+
args=[self.limit, self.window_ms, now + i], # +i for uniqueness in same ms
|
|
71
|
+
)
|
|
72
|
+
last_result = RateLimitResult(
|
|
73
|
+
allowed=bool(int(result_raw[0])),
|
|
74
|
+
remaining=int(result_raw[1]),
|
|
75
|
+
reset_after_ms=int(result_raw[2]),
|
|
76
|
+
retry_after_ms=int(result_raw[3]),
|
|
77
|
+
algorithm=ALGORITHM,
|
|
78
|
+
key=key,
|
|
79
|
+
)
|
|
80
|
+
if not last_result.allowed:
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
assert last_result is not None
|
|
84
|
+
return last_result
|