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.
@@ -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
+ [![CI](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml)
21
+ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue&logo=redis)](https://www.python.org/downloads/)
22
+ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
23
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v0.json)](https://astral.sh/ruff)
24
+ [![Checked with ty](https://img.shields.io/badge/checked%20with-ty-262626?style=flat&logo=python&logoColor=3776AB)](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
+ [![CI](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/dennis-nw/fastapi-sluice/actions/workflows/ci.yml)
10
+ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue&logo=redis)](https://www.python.org/downloads/)
11
+ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
12
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v0.json)](https://astral.sh/ruff)
13
+ [![Checked with ty](https://img.shields.io/badge/checked%20with-ty-262626?style=flat&logo=python&logoColor=3776AB)](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
+ ]
@@ -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