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.
Files changed (32) hide show
  1. sluice_limiter-0.1.1/LICENSE +21 -0
  2. sluice_limiter-0.1.1/PKG-INFO +165 -0
  3. sluice_limiter-0.1.1/README.md +129 -0
  4. sluice_limiter-0.1.1/pyproject.toml +66 -0
  5. sluice_limiter-0.1.1/setup.cfg +4 -0
  6. sluice_limiter-0.1.1/sluice/__init__.py +21 -0
  7. sluice_limiter-0.1.1/sluice/algorithms/__init__.py +14 -0
  8. sluice_limiter-0.1.1/sluice/algorithms/base.py +64 -0
  9. sluice_limiter-0.1.1/sluice/algorithms/sliding_window_counter.py +83 -0
  10. sluice_limiter-0.1.1/sluice/algorithms/sliding_window_log.py +84 -0
  11. sluice_limiter-0.1.1/sluice/algorithms/token_bucket.py +81 -0
  12. sluice_limiter-0.1.1/sluice/backends/__init__.py +15 -0
  13. sluice_limiter-0.1.1/sluice/backends/base.py +40 -0
  14. sluice_limiter-0.1.1/sluice/backends/fallback_backend.py +212 -0
  15. sluice_limiter-0.1.1/sluice/backends/hybrid_backend.py +271 -0
  16. sluice_limiter-0.1.1/sluice/backends/redis_backend.py +114 -0
  17. sluice_limiter-0.1.1/sluice/middleware/__init__.py +22 -0
  18. sluice_limiter-0.1.1/sluice/middleware/fastapi.py +122 -0
  19. sluice_limiter-0.1.1/sluice/middleware/policy.py +190 -0
  20. sluice_limiter-0.1.1/sluice/middleware/utils.py +170 -0
  21. sluice_limiter-0.1.1/sluice/scripts/__init__.py +0 -0
  22. sluice_limiter-0.1.1/sluice_limiter.egg-info/PKG-INFO +165 -0
  23. sluice_limiter-0.1.1/sluice_limiter.egg-info/SOURCES.txt +30 -0
  24. sluice_limiter-0.1.1/sluice_limiter.egg-info/dependency_links.txt +1 -0
  25. sluice_limiter-0.1.1/sluice_limiter.egg-info/requires.txt +16 -0
  26. sluice_limiter-0.1.1/sluice_limiter.egg-info/top_level.txt +1 -0
  27. sluice_limiter-0.1.1/tests/test_algorithms.py +242 -0
  28. sluice_limiter-0.1.1/tests/test_circuit_breaker.py +261 -0
  29. sluice_limiter-0.1.1/tests/test_concurrency.py +117 -0
  30. sluice_limiter-0.1.1/tests/test_fallback.py +238 -0
  31. sluice_limiter-0.1.1/tests/test_hybrid.py +265 -0
  32. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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