Skip to content

Commit 33ab701

Browse files
committed
Added rate limiter for fastAPI template.
1 parent 8a584ca commit 33ab701

22 files changed

Lines changed: 399 additions & 1 deletion

.env

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,10 @@ SENTRY_DSN=
4343
# Configure these with your own Docker registry images
4444
DOCKER_IMAGE_BACKEND=backend
4545
DOCKER_IMAGE_FRONTEND=frontend
46+
47+
# Redis config
48+
REDIS_URL=redis://redis:6379/0
49+
50+
# Rate Limiting config
51+
RATE_LIMITER_STRATEGY=sliding_window
52+
RATE_LIMIT_FAIL_OPEN=false
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- KEYS[1] = key
2+
-- ARGV[1] = now_ms
3+
-- ARGV[2] = window_ms
4+
-- ARGV[3] = limit
5+
-- ARGV[4] = member
6+
7+
local key = KEYS[1]
8+
local now = tonumber(ARGV[1])
9+
local window = tonumber(ARGV[2])
10+
local limit = tonumber(ARGV[3])
11+
local member = ARGV[4]
12+
13+
local min_score = now - window
14+
15+
-- remove old entries
16+
redis.call('ZREMRANGEBYSCORE', key, 0, min_score)
17+
18+
-- add current
19+
redis.call('ZADD', key, now, member)
20+
21+
-- count
22+
local cnt = redis.call('ZCARD', key)
23+
24+
-- expire same as window
25+
redis.call('PEXPIRE', key, window)
26+
27+
-- fetch oldest
28+
local earliest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
29+
local oldest_ts = 0
30+
if earliest ~= false and earliest ~= nil and #earliest >= 2 then
31+
oldest_ts = earliest[2]
32+
end
33+
34+
return {cnt, oldest_ts}

backend/app/api/routes/users.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
get_current_active_superuser,
1212
)
1313
from app.core.config import settings
14+
from app.core.rate_limiter.key_strategy.key_strategy_enum import KeyStrategyName
15+
from app.core.rate_limiter.rate_limiter import RateLimiter
1416
from app.core.security import get_password_hash, verify_password
1517
from app.models import (
1618
Item,
@@ -31,7 +33,8 @@
3133

3234
@router.get(
3335
"/",
34-
dependencies=[Depends(get_current_active_superuser)],
36+
dependencies=[Depends(RateLimiter(limit=10, window_seconds=60, key_policy=KeyStrategyName.IP)),
37+
Depends(get_current_active_superuser)],
3538
response_model=UsersPublic,
3639
)
3740
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:

backend/app/core/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,32 @@ class Settings(BaseSettings):
4141
list[AnyUrl] | str, BeforeValidator(parse_cors)
4242
] = []
4343

44+
# Correct Redis default inside Docker Compose
45+
REDIS_URL: str = ""
46+
RATE_LIMITER_STRATEGY: Literal[
47+
"none", "sliding_window"
48+
] = "none"
49+
RATE_LIMIT_FAIL_OPEN: bool = True
50+
4451
@computed_field # type: ignore[prop-decorator]
4552
@property
4653
def all_cors_origins(self) -> list[str]:
4754
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
4855
self.FRONTEND_HOST
4956
]
5057

58+
@computed_field # type: ignore[prop-decorator]
59+
@property
60+
def rate_limit_enabled(self) -> bool:
61+
"""
62+
Returns True if rate limiting should be enabled based on strategy.
63+
Mirrors the style of all_cors_origins.
64+
"""
65+
strategy = (self.RATE_LIMITER_STRATEGY or "").strip().lower()
66+
redis_url = (self.REDIS_URL or "").strip()
67+
68+
return strategy not in ("", "none") and bool(redis_url)
69+
5170
PROJECT_NAME: str
5271
SENTRY_DSN: HttpUrl | None = None
5372
POSTGRES_SERVER: str

backend/app/core/rate_limiter/__init__.py

Whitespace-only changes.

backend/app/core/rate_limiter/key_strategy/__init__.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from starlette.requests import Request
2+
from app.core.rate_limiter.key_strategy.key_strategy import KeyStrategy
3+
4+
5+
class HeaderKeyStrategy(KeyStrategy):
6+
def __init__(self, header_name: str = "X-Client-ID"):
7+
self.header_name = header_name
8+
9+
def get_key(self, request: Request, route_path: str) -> str:
10+
value = request.headers.get(self.header_name, "unknown")
11+
return f"header:{self.header_name}:{value}:{route_path}"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from app.core.rate_limiter.key_strategy.key_strategy import KeyStrategy
2+
from starlette.requests import Request
3+
4+
class IPKeyStrategy(KeyStrategy):
5+
"""Generate rate limit key based on client IP address."""
6+
7+
def get_key(self, request: Request, route_path: str) -> str:
8+
client_ip = request.client.host if request.client else "unknown"
9+
return f"ip:{client_ip}:{route_path}"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from abc import ABC, abstractmethod
2+
from starlette.requests import Request
3+
4+
class KeyStrategy(ABC):
5+
"""Base interface for rate-limit key generation."""
6+
7+
@abstractmethod
8+
def get_key(self, request: Request, route_path: str) -> str:
9+
"""Return unique identifier string (e.g., 'ip:127.0.0.1')"""
10+
raise NotImplementedError
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from enum import Enum
2+
3+
class KeyStrategyName(str, Enum):
4+
IP = "ip"
5+
HEADER = "header"

0 commit comments

Comments
 (0)