284 lines
6.3 KiB
Markdown
284 lines
6.3 KiB
Markdown
# fastapi-420
|
|
|
|
Production rate limiting for FastAPI. Uses HTTP code 420 "Enhance Your Calm" because 429 is boring.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
pip install fastapi-420
|
|
```
|
|
|
|
For Redis support:
|
|
|
|
```bash
|
|
pip install fastapi-420[redis]
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
Three ways to add rate limiting. Pick what fits your app.
|
|
|
|
### Middleware (global)
|
|
|
|
Limits all routes automatically.
|
|
|
|
```python
|
|
from fastapi import FastAPI
|
|
from fastapi_420 import RateLimiter, RateLimitMiddleware
|
|
|
|
app = FastAPI()
|
|
limiter = RateLimiter()
|
|
|
|
app.add_middleware(
|
|
RateLimitMiddleware,
|
|
limiter=limiter,
|
|
default_limit="100/minute",
|
|
)
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
return {"message": "hello"}
|
|
```
|
|
|
|
### Decorator (per route)
|
|
|
|
Fine grained control on specific endpoints.
|
|
|
|
```python
|
|
from fastapi import FastAPI, Request
|
|
from fastapi_420 import RateLimiter
|
|
|
|
app = FastAPI()
|
|
limiter = RateLimiter()
|
|
|
|
@app.get("/search")
|
|
@limiter.limit("30/minute")
|
|
async def search(request: Request, q: str):
|
|
return {"results": []}
|
|
|
|
@app.post("/upload")
|
|
@limiter.limit("5/minute", "20/hour")
|
|
async def upload(request: Request):
|
|
return {"status": "ok"}
|
|
```
|
|
|
|
### Dependency (FastAPI style)
|
|
|
|
Works with FastAPI's dependency injection.
|
|
|
|
```python
|
|
from fastapi import FastAPI, Depends
|
|
from fastapi_420 import RateLimiter, RateLimitDep, set_global_limiter
|
|
|
|
app = FastAPI()
|
|
limiter = RateLimiter()
|
|
set_global_limiter(limiter)
|
|
|
|
@app.get("/api/data", dependencies=[Depends(RateLimitDep("50/minute"))])
|
|
async def get_data():
|
|
return {"data": []}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Different limits for different endpoints
|
|
|
|
Auth endpoints get strict limits. Public endpoints stay relaxed.
|
|
|
|
```python
|
|
from fastapi_420 import ScopedRateLimiter
|
|
|
|
auth_limiter = ScopedRateLimiter(
|
|
prefix="/auth",
|
|
default_rules=["5/minute"],
|
|
endpoint_rules={
|
|
"POST:/auth/login": ["3/minute", "10/hour"],
|
|
"POST:/auth/register": ["2/minute"],
|
|
},
|
|
)
|
|
|
|
@app.post("/auth/login", dependencies=[Depends(auth_limiter)])
|
|
async def login():
|
|
...
|
|
```
|
|
|
|
### Using Redis
|
|
|
|
Memory storage works fine for single instances. Redis for distributed apps.
|
|
|
|
```python
|
|
from fastapi_420 import RateLimiter, RateLimiterSettings, StorageSettings
|
|
|
|
settings = RateLimiterSettings(
|
|
storage=StorageSettings(
|
|
REDIS_URL="redis://localhost:6379/0",
|
|
),
|
|
)
|
|
|
|
limiter = RateLimiter(settings=settings)
|
|
```
|
|
|
|
If Redis goes down, the limiter falls back to memory automatically.
|
|
|
|
### Trusting proxy headers
|
|
|
|
Behind nginx or a load balancer? Trust the forwarded headers.
|
|
|
|
```python
|
|
from fastapi_420 import FingerprintSettings, RateLimiterSettings
|
|
|
|
settings = RateLimiterSettings(
|
|
fingerprint=FingerprintSettings(
|
|
TRUST_X_FORWARDED_FOR=True,
|
|
),
|
|
)
|
|
```
|
|
|
|
### Custom identification
|
|
|
|
Rate limit by user ID instead of IP.
|
|
|
|
```python
|
|
def get_user_id(request):
|
|
return request.state.user_id or request.client.host
|
|
|
|
@app.get("/api/resource")
|
|
@limiter.limit("100/minute", key_func=get_user_id)
|
|
async def resource(request: Request):
|
|
...
|
|
```
|
|
|
|
## Configuration Reference
|
|
|
|
All settings with their defaults.
|
|
|
|
### RateLimiterSettings
|
|
|
|
```python
|
|
from fastapi_420 import RateLimiterSettings
|
|
from fastapi_420.types import Algorithm
|
|
|
|
RateLimiterSettings(
|
|
# Algorithm
|
|
ALGORITHM=Algorithm.SLIDING_WINDOW, # SLIDING_WINDOW | TOKEN_BUCKET | FIXED_WINDOW
|
|
|
|
# Defaults applied when no rules specified
|
|
DEFAULT_LIMIT="100/minute",
|
|
DEFAULT_LIMITS=["100/minute"], # list form, multiple rules
|
|
|
|
# Storage key configuration
|
|
KEY_PREFIX="rl",
|
|
KEY_VERSION="v1",
|
|
|
|
# Response behavior
|
|
INCLUDE_HEADERS=True, # add RateLimit-* headers
|
|
HTTP_420_MESSAGE="Enhance Your Calm",
|
|
HTTP_420_DETAIL={"error": "rate_limit_exceeded", "message": "Enhance Your Calm"},
|
|
|
|
# Failure handling
|
|
FAIL_OPEN=True, # allow requests if storage fails
|
|
LOG_VIOLATIONS=True, # log when limits exceeded
|
|
|
|
# Nested settings (see below)
|
|
storage=StorageSettings(...),
|
|
fingerprint=FingerprintSettings(...),
|
|
)
|
|
```
|
|
|
|
### StorageSettings
|
|
|
|
```python
|
|
from fastapi_420 import StorageSettings
|
|
|
|
StorageSettings(
|
|
# Redis (optional, falls back to memory if not set or unavailable)
|
|
REDIS_URL=None, # "redis://localhost:6379/0"
|
|
REDIS_KEY_PREFIX="rl",
|
|
REDIS_SOCKET_TIMEOUT=5.0,
|
|
REDIS_SOCKET_CONNECT_TIMEOUT=5.0,
|
|
REDIS_MAX_CONNECTIONS=50,
|
|
REDIS_RETRY_ON_TIMEOUT=True,
|
|
REDIS_HEALTH_CHECK_INTERVAL=30,
|
|
|
|
# Memory storage
|
|
MEMORY_MAX_KEYS=100_000, # max keys before LRU eviction
|
|
MEMORY_CLEANUP_INTERVAL=60, # seconds between expired key cleanup
|
|
)
|
|
```
|
|
|
|
### FingerprintSettings
|
|
|
|
Controls how clients are identified. Higher levels are stricter but may cause issues with legitimate users behind proxies.
|
|
|
|
```python
|
|
from fastapi_420 import FingerprintSettings
|
|
from fastapi_420.types import FingerprintLevel
|
|
|
|
FingerprintSettings(
|
|
LEVEL=FingerprintLevel.NORMAL, # RELAXED | NORMAL | STRICT
|
|
|
|
# What to trust
|
|
TRUST_X_FORWARDED_FOR=False, # trust X-Forwarded-For header
|
|
TRUSTED_PROXIES=[], # IPs that can set forwarded headers
|
|
|
|
# IPv6 handling
|
|
IPV6_PREFIX_LENGTH=64, # normalize IPv6 to /64 prefix
|
|
)
|
|
```
|
|
|
|
**Fingerprint Levels:**
|
|
|
|
| Level | What it uses |
|
|
|-------|-------------|
|
|
| RELAXED | IP only |
|
|
| NORMAL | IP + User-Agent |
|
|
| STRICT | IP + User-Agent + Accept headers + Auth token hash |
|
|
|
|
### Algorithms
|
|
|
|
| Algorithm | Behavior | Best for |
|
|
|-----------|----------|----------|
|
|
| SLIDING_WINDOW | Smooth, accurate limits | Most cases (default) |
|
|
| TOKEN_BUCKET | Allows short bursts | APIs with bursty traffic |
|
|
| FIXED_WINDOW | Simple, less accurate at window edges | High performance needs |
|
|
|
|
## Rate Limit Format
|
|
|
|
Rules follow the pattern `{requests}/{period}`:
|
|
|
|
```
|
|
100/minute
|
|
50/hour
|
|
1000/day
|
|
10/second
|
|
```
|
|
|
|
Multiple rules stack. The most restrictive one applies:
|
|
|
|
```python
|
|
@limiter.limit("10/second", "100/minute", "1000/hour")
|
|
async def endpoint(request: Request):
|
|
...
|
|
```
|
|
|
|
## Running the Example
|
|
|
|
```bash
|
|
cd examples
|
|
docker compose up -d
|
|
pip install fastapi uvicorn
|
|
python app.py
|
|
```
|
|
|
|
Then hit http://localhost:8000/docs to see the API.
|
|
|
|
## Why 420?
|
|
|
|
Twitter used HTTP 420 "Enhance Your Calm" for rate limiting before switching to 429. It is more fun.
|
|
|
|
The exception is called `EnhanceYourCalm` and the response tells clients to chill out.
|
|
|
|
## License
|
|
|
|
MIT
|