Skip to content

Commit 8726621

Browse files
authored
Merge pull request #22 from JsNcAr/feat/limits-rate-limiter
feat(limiter): rate limiting on limits directly, slowapi removed
2 parents cf5cc84 + 23cb90c commit 8726621

14 files changed

Lines changed: 238 additions & 107 deletions

File tree

‎.env.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,6 @@ BCRYPT_ROUNDS=12
6060
# Largest request body in bytes; larger requests get 413 before any route runs
6161
MAX_REQUEST_BODY_BYTES=1048576
6262

63-
# --- Rate limits (per client IP, slowapi/limits syntax: 10/minute, 100/hour) ---
63+
# --- Rate limits (per client IP, `limits` syntax: 10/minute, 100/hour) ---
6464
RATE_LIMIT_LOGIN=60/minute
6565
RATE_LIMIT_REGISTER=10/minute

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ notes (see [docs/releasing.md](docs/releasing.md)).
8080
`RequestBodyLimitMiddleware` answers 413 before any route runs (#13).
8181

8282
### Changed
83+
- The rate limiter is built directly on `limits` (fixed window, in-process
84+
memory) instead of slowapi, whose `asyncio.iscoroutinefunction` call is
85+
removed in Python 3.16 and unfixed upstream. A limited route now declares
86+
`Depends(limiter.limit(...))` instead of a decorator plus `request` and
87+
`response` parameters it never used. Same limits, same per-IP and per-route
88+
counting, same `429` body and `Retry-After`; the `429` now also carries the
89+
`X-RateLimit` headers, and `X-RateLimit-Reset` is a whole number of seconds.
90+
The `pytest.ini` warning filter for slowapi is gone, so the suite runs with
91+
no filtered warnings (#22).
8392
- Python 3.15: `.python-version` says `3.15`, `requires-python` is
8493
`>=3.15,<4.0`, Black targets `py315`, mypy checks against 3.15 and the GitLab
8594
pipeline runs on `python:3.15-slim`. Every dependency ships a 3.15 wheel or is

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ configuration right. That work is done here, tested, and documented.
1919
time and return the same body
2020
- Passwords hashed with bcrypt over a SHA-256 pre-hash, off the event loop
2121
- Client API key as a declared OpenAPI security scheme on every `/api` route
22-
- Rate limits per client IP on login and registration (slowapi)
22+
- Rate limits per client IP on login and registration (`limits`)
2323
- User self-service: read, update, change password and delete the own account
2424
- Async PostgreSQL through SQLModel and asyncpg; health check that pings it
2525
- One `config.py` reads and validates every environment variable at startup

‎docs/api.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ same id; quote it when reporting a problem.
3030

3131
**Rate limits**: `POST /token` and `POST /api/users/` answer with
3232
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix
33-
time at which the window resets); over the limit, a `429` with `Retry-After`.
33+
time at which the window resets); over the limit, a `429` with `Retry-After`
34+
and the same three headers with nothing remaining.
3435

3536
**Auth** column: `key` is the `X-API-Key` header, `token` is
3637
`Authorization: Bearer <jwt>`. See [Authentication](authentication.md) for the

‎docs/known_issues.md‎

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ looks like. Remove an entry in the same change that fixes it.
2424
| Code | [Startup validation and the login limit are untested](#startup-validation-and-the-login-limit-are-untested) | Low |
2525
| Code | [Application code detects the test runner](#application-code-detects-the-test-runner) | Low |
2626
| Code | [The root banner reports healthy without checking](#the-root-banner-reports-healthy-without-checking) | Low |
27-
| Dependencies | [slowapi calls an asyncio API removed in Python 3.16](#slowapi-calls-an-asyncio-api-removed-in-python-316) | Low |
2827

2928
---
3029

@@ -152,8 +151,8 @@ effective limit; a restart resets it. Behind a reverse proxy on another host,
152151
uvicorn must be told to trust its forwarded headers (`--forwarded-allow-ips`),
153152
or every client shares one bucket.
154153

155-
**Fix:** shared storage for the counts (slowapi supports Redis) when running
156-
more than one worker.
154+
**Fix:** shared storage for the counts (`limits` has Redis, Memcached and
155+
MongoDB storages) when running more than one worker.
157156

158157
### No metrics, tracing or error tracking
159158

@@ -247,23 +246,3 @@ healthy service with its database down.
247246

248247
**Fix:** drop the `status` field from the banner, or have it call the same
249248
`ping()` as `/health`.
250-
251-
---
252-
253-
## Dependencies
254-
255-
### slowapi calls an asyncio API removed in Python 3.16
256-
257-
**Where:** `slowapi/extension.py` (version 0.1.10), surfaced by `pytest.ini`.
258-
259-
On Python 3.14 and 3.15 the test run reports `DeprecationWarning: 'asyncio.iscoroutinefunction'
260-
is deprecated and slated for removal in Python 3.16; use
261-
inspect.iscoroutinefunction()`, raised from slowapi when it wraps a rate-limited
262-
route. Nothing is wrong today; on Python 3.16 the rate limiter would stop
263-
working unless slowapi releases a fix. `pytest.ini` filters this one warning
264-
for the `slowapi` module only, so the summary stays readable; the filter must go
265-
when slowapi fixes it or when this project replaces slowapi.
266-
267-
**Fix:** upgrade slowapi once a release uses `inspect.iscoroutinefunction`, then
268-
remove the filter; if none appears before 3.16 matters, replace slowapi (the
269-
limiter lives in one module, `limiter.py`).

‎poetry.lock‎

Lines changed: 1 addition & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ dependencies = [
2222
# needs, is only installed through the [asyncio] extra.
2323
"sqlalchemy[asyncio] (>=2.1.4,<3.0)",
2424
"asyncpg (>=0.32.0,<1.0)",
25-
"slowapi (>=0.1.10,<1.0)",
25+
"limits (>=5.8.0,<6.0)",
2626
"pyjwt (>=2.15.1,<3)",
2727
]
2828

‎pytest.ini‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ addopts = -ra -q
44
testpaths = tests
55
pythonpath = src
66
python_files = test_*.py
7-
# Third-party only, tracked in docs/known_issues.md (slowapi and Python 3.16).
8-
# Never a blanket ignore: a new deprecation in our own code must stay visible.
9-
filterwarnings =
10-
ignore:'asyncio.iscoroutinefunction' is deprecated:DeprecationWarning:slowapi
7+
# No warning filters: a deprecation in our code or a dependency stays visible
8+
# in the summary and is dealt with (or recorded in docs/known_issues.md).
119
asyncio_mode = auto
1210
asyncio_default_fixture_loop_scope = function

‎src/basic_api/config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def minimum_secret_bytes(algorithm: str) -> int:
253253
raise RuntimeError("MAX_REQUEST_BODY_BYTES must be a positive integer")
254254

255255
# --- Rate limits -------------------------------------------------------------
256-
# slowapi/limits syntax, e.g. "10/minute", "100/hour"; validated in limiter.py.
256+
# `limits` syntax, e.g. "10/minute", "100/hour"; validated in limiter.py.
257257
# Per client IP and per process (each uvicorn worker keeps its own count).
258258
RATE_LIMIT_LOGIN = os.getenv("RATE_LIMIT_LOGIN", "60/minute")
259259
RATE_LIMIT_REGISTER = os.getenv("RATE_LIMIT_REGISTER", "10/minute")

‎src/basic_api/limiter.py‎

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
"""
22
Rate limiting shared by the app and its routers.
33
4-
The Limiter lives in its own module because main.py imports the routers; a
5-
router importing it from main.py would be a circular import.
4+
Built on the `limits` library: a fixed window per client IP and per route,
5+
counted in this process's memory. The Limiter lives in its own module because
6+
main.py imports the routers; a router importing it from main.py would be a
7+
circular import.
68
79
Limits are per client IP, as uvicorn sees it (behind a reverse proxy, make sure
810
uvicorn trusts the proxy's forwarded headers), and per process: with several
911
workers each keeps its own count, so the effective limit is multiplied.
1012
11-
A limited route answers with X-RateLimit-Limit, X-RateLimit-Remaining and
12-
X-RateLimit-Reset (Unix time), so a client can pace itself before a 429. slowapi
13-
writes them onto the route's `response: Response` parameter, which is why the
14-
limited routes declare one they never touch.
13+
A limited route declares `Depends(limiter.limit("10/minute"))`. The dependency
14+
counts the request, writes X-RateLimit-Limit, X-RateLimit-Remaining and
15+
X-RateLimit-Reset (Unix time) onto the response so a client can pace itself
16+
before a 429, and raises RateLimitExceeded over the limit; main.py maps that to
17+
the 429 built by rate_limit_exceeded_handler.
1518
"""
1619

17-
from fastapi import Request
20+
import math
21+
from collections.abc import Awaitable, Callable
22+
23+
from fastapi import Request, Response
1824
from fastapi.responses import JSONResponse
19-
from limits import parse_many
20-
from slowapi import Limiter
21-
from slowapi.errors import RateLimitExceeded
22-
from slowapi.util import get_remote_address
25+
from limits import RateLimitItem, parse_many
26+
from limits.storage import MemoryStorage
27+
from limits.strategies import FixedWindowRateLimiter
2328

2429
from .config import RATE_LIMIT_LOGIN, RATE_LIMIT_REGISTER
2530

@@ -33,30 +38,143 @@
3338
except ValueError as e:
3439
raise RuntimeError(f"{_name}={_value!r} is not a valid rate limit: {e}")
3540

36-
limiter = Limiter(key_func=get_remote_address, headers_enabled=True)
41+
42+
class RateLimitExceeded(Exception):
43+
"""
44+
A request went over one of its route's limits.
45+
46+
Attributes:
47+
limit: The limit that was hit.
48+
reset_time: Unix time at which that limit's window resets.
49+
"""
50+
51+
def __init__(self, limit: RateLimitItem, reset_time: float) -> None:
52+
super().__init__(f"rate limit exceeded: {limit}")
53+
self.limit = limit
54+
self.reset_time = reset_time
55+
56+
57+
def client_address(request: Request) -> str:
58+
"""
59+
Key requests by client IP.
60+
61+
Args:
62+
request: The incoming request.
63+
64+
Returns:
65+
The peer address uvicorn reports, or "unknown" when there is none (an
66+
in-process test client, for instance).
67+
"""
68+
return request.client.host if request.client else "unknown"
69+
70+
71+
def route_scope(request: Request) -> str:
72+
"""
73+
Name the counter a request belongs to: its method and route path.
74+
75+
The route's path template (`/api/users/`) rather than the request URL, so a
76+
route with path parameters still has one counter.
77+
78+
Args:
79+
request: The incoming request, already matched to a route.
80+
81+
Returns:
82+
A string such as "POST /api/users/".
83+
"""
84+
route = request.scope.get("route")
85+
path = getattr(route, "path", request.url.path)
86+
return f"{request.method} {path}"
87+
88+
89+
class Limiter:
90+
"""
91+
Fixed-window counters per client and per route, in this process's memory.
92+
93+
Attributes:
94+
enabled: False makes every limit a no-op that writes no headers. The
95+
test suite turns it off because every test client shares one IP.
96+
key_func: Maps a request to its client key; client_address by default.
97+
"""
98+
99+
def __init__(
100+
self,
101+
key_func: Callable[[Request], str] = client_address,
102+
*,
103+
enabled: bool = True,
104+
) -> None:
105+
self.enabled = enabled
106+
self.key_func = key_func
107+
self._storage = MemoryStorage()
108+
self._strategy = FixedWindowRateLimiter(self._storage)
109+
110+
def reset(self) -> None:
111+
"""Forget every count."""
112+
self._storage.reset()
113+
114+
def limit(self, value: str) -> Callable[[Request, Response], Awaitable[None]]:
115+
"""
116+
Build the dependency that enforces `value` on the route declaring it.
117+
118+
Args:
119+
value: One or more limits in `limits` syntax ("10/minute",
120+
"100/hour;1000/day"), each of which must hold.
121+
122+
Returns:
123+
A dependency for the route's `dependencies=[Depends(...)]`. It
124+
raises RateLimitExceeded when a limit is hit; otherwise it writes
125+
the X-RateLimit headers of the limit with the least budget left.
126+
"""
127+
items = parse_many(value)
128+
129+
async def enforce(request: Request, response: Response) -> None:
130+
if not self.enabled:
131+
return
132+
key = self.key_func(request)
133+
scope = route_scope(request)
134+
for item in items:
135+
if not self._strategy.hit(item, key, scope):
136+
stats = self._strategy.get_window_stats(item, key, scope)
137+
raise RateLimitExceeded(item, stats.reset_time)
138+
item, stats = min(
139+
((i, self._strategy.get_window_stats(i, key, scope)) for i in items),
140+
key=lambda pair: pair[1].remaining,
141+
)
142+
response.headers["X-RateLimit-Limit"] = str(item.amount)
143+
response.headers["X-RateLimit-Remaining"] = str(stats.remaining)
144+
response.headers["X-RateLimit-Reset"] = str(math.ceil(stats.reset_time))
145+
146+
return enforce
147+
148+
149+
limiter = Limiter()
37150

38151

39152
def rate_limit_exceeded_handler(request: Request, exc: Exception) -> JSONResponse:
40153
"""
41154
Answer a limit hit in the API's usual error shape, with a Retry-After.
42155
43-
slowapi's default handler returns {"error": ...}; clients read {"detail":
44-
...} like every other error. The wait is the limit's window, the longest a
45-
client has to wait under the default fixed-window strategy.
156+
Clients read {"detail": ...} like every other error. The wait is the
157+
limit's window, the longest a client has to wait under the fixed-window
158+
strategy; the X-RateLimit headers say when the window actually resets.
46159
47160
Args:
48161
request: The request that hit the limit.
49-
exc: The RateLimitExceeded raised by slowapi (typed as Exception
162+
exc: The RateLimitExceeded raised by the limiter (typed as Exception
50163
because that is Starlette's handler signature).
51164
52165
Returns:
53-
A 429 JSON response with a Retry-After header in seconds.
166+
A 429 JSON response with a Retry-After header in seconds and the
167+
X-RateLimit headers with no budget left.
54168
"""
55169
assert isinstance(exc, RateLimitExceeded)
56-
wrapper = exc.limit
57-
retry_after = wrapper.limit.get_expiry() if wrapper is not None else 60
170+
retry_after = exc.limit.get_expiry()
58171
return JSONResponse(
59172
status_code=429,
60173
content={"detail": f"Too many requests. Try again in {retry_after} seconds."},
61-
headers={"Retry-After": str(retry_after)},
174+
headers={
175+
"Retry-After": str(retry_after),
176+
"X-RateLimit-Limit": str(exc.limit.amount),
177+
"X-RateLimit-Remaining": "0",
178+
"X-RateLimit-Reset": str(math.ceil(exc.reset_time)),
179+
},
62180
)

0 commit comments

Comments
 (0)