34 KiB
Implementation Guide
This document walks through the actual code. We'll build key features step by step and explain the decisions along the way.
File Structure Walkthrough
src/fastapi_420/
├── __init__.py # Package exports
├── limiter.py # RateLimiter class (main entry point)
├── middleware.py # ASGI middleware integration
├── dependencies.py # FastAPI dependency injection helpers
├── exceptions.py # Custom exceptions (EnhanceYourCalm)
├── config.py # Pydantic settings with validation
├── types.py # Data structures and protocols
├── algorithms/
│ ├── __init__.py # Algorithm factory
│ ├── base.py # BaseAlgorithm protocol
│ ├── sliding_window.py # Recommended algorithm
│ ├── token_bucket.py # Burst-tolerant algorithm
│ └── fixed_window.py # Simple algorithm
├── storage/
│ ├── __init__.py # Storage factory
│ ├── memory.py # In-memory backend
│ ├── redis_backend.py # Redis backend
│ └── lua/
│ ├── sliding_window.lua # Atomic sliding window
│ ├── token_bucket.lua # Atomic token bucket
│ └── fixed_window.lua # Atomic fixed window
├── fingerprinting/
│ ├── __init__.py
│ ├── ip.py # IP extraction + IPv6 normalization
│ ├── headers.py # Header fingerprinting
│ ├── auth.py # JWT/API key extraction
│ └── composite.py # Combines all methods
└── defense/
├── __init__.py
├── circuit_breaker.py # Global DDoS protection
└── layers.py # Three-layer defense
Building the Core: Sliding Window Algorithm
Step 1: The Algorithm Interface
What we're building: A protocol that all rate limiting algorithms implement.
Create src/fastapi_420/algorithms/base.py:
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi_420.storage import Storage
from fastapi_420.types import RateLimitResult, RateLimitRule
class BaseAlgorithm(ABC):
"""
Abstract base class for rate limiting algorithms
"""
@property
@abstractmethod
def name(self) -> str:
"""Algorithm name for logging and debugging"""
...
@abstractmethod
async def check(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
timestamp: float | None = None,
) -> RateLimitResult:
"""Check if request is allowed under rate limit"""
...
Why this code works:
- Lines 3-7: Type checking imports prevent circular dependencies. Storage and types are only imported when running type checkers, not at runtime.
- Lines 11-15: Abstract property forces subclasses to define a name. Used in logs to identify which algorithm is running.
- Lines 17-25: The check method signature is the contract. Every algorithm must accept storage, key, rule, and optional timestamp. Return type is always RateLimitResult.
Common mistakes here:
# Wrong approach
class BaseAlgorithm:
def check(self, key, limit): # No type hints
count = storage.get(key) # Where does storage come from?
return count < limit # Returns bool, not RateLimitResult
# Why this fails:
# 1. No dependency injection for storage
# 2. Missing type hints make it hard to use correctly
# 3. Returns primitive type instead of rich result object
Step 2: Implementing Sliding Window
Now we need to actually implement the algorithm.
In src/fastapi_420/algorithms/sliding_window.py (lines 18-43):
class SlidingWindowAlgorithm(BaseAlgorithm):
"""
Sliding window counter algorithm
Achieves ~99.997% accuracy with O(1) memory per client.
Uses weighted interpolation between two fixed windows.
"""
@property
def name(self) -> str:
return Algorithm.SLIDING_WINDOW.value
async def check(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Check and increment counter using sliding window algorithm
"""
return await storage.increment(
key=key,
window_seconds=rule.window_seconds,
limit=rule.requests,
timestamp=timestamp,
)
What's happening:
- Lines 18-23: The docstring explains this is the recommended algorithm and why (accuracy vs memory tradeoff).
- Lines 25-27: The name property returns the enum value "sliding_window" for logging.
- Lines 29-43: The check method delegates to storage. The algorithm logic lives in storage backends (Redis Lua script or Python for memory).
Why we do it this way: Separation of concerns. The algorithm defines what to do (sliding window counter). The storage defines how to do it (Lua script for Redis, Python locks for memory). This allows different storage backends to optimize implementation while the algorithm interface stays consistent.
Alternative approaches:
- Approach A: Algorithm contains logic: Would require duplicating increment logic for each storage backend. Error prone.
- Approach B: Generic increment method: Simpler but can't optimize for storage-specific features (Redis Lua atomicity).
Step 3: The Storage Backend - Redis Lua Script
The actual sliding window math happens in Lua for atomicity.
In src/fastapi_420/storage/lua/sliding_window.lua:
local key = KEYS[1]
local window_seconds = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local current_window = math.floor(now / window_seconds)
local previous_window = current_window - 1
local elapsed_ratio = (now % window_seconds) / window_seconds
local current_key = key .. ":" .. current_window
local previous_key = key .. ":" .. previous_window
local current_count = tonumber(redis.call('GET', current_key)) or 0
local previous_count = tonumber(redis.call('GET', previous_key)) or 0
local weighted_count = math.floor(
previous_count * (1 - elapsed_ratio) + current_count
)
local reset_after = window_seconds - (now % window_seconds)
if weighted_count >= limit then
return {0, 0, reset_after, reset_after} -- Denied
end
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window_seconds * 2)
local new_weighted = math.floor(
previous_count * (1 - elapsed_ratio) + current_count + 1
)
local remaining = math.max(0, limit - new_weighted)
return {1, remaining, reset_after, 0} -- Allowed
Key parts explained:
Lines 6-8 - Calculate which windows we're in:
local current_window = math.floor(now / window_seconds)
local elapsed_ratio = (now % window_seconds) / window_seconds
If now=1234 and window_seconds=60, current_window=20 (we're in the 20th minute). elapsed_ratio=0.57 (57% through the current minute).
Lines 16-18 - The core algorithm:
local weighted_count = math.floor(
previous_count * (1 - elapsed_ratio) + current_count
)
This is the sliding window formula. If we're 57% through the current window, previous window counts for 43% and current counts for 100%. Example: previous=80, current=30, weighted=80*0.43+30=64.4.
Lines 25-26 - Atomic increment:
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window_seconds * 2)
Increment the current window counter and set expiration. Expiration is 2x window size to ensure previous window data is available for interpolation.
What happens if you remove this: Without Lua scripts, you'd need to:
- GET current_key
- GET previous_key
- Calculate weighted_count
- Check if over limit
- INCR current_key
- SET expiration
Between steps 4 and 5, another request could increment the counter. Race condition. Two requests both see count=99, both increment, both allowed when limit is 100. Lua executes atomically, preventing this.
Building the Fingerprinting System
The Problem
What we're building: Reliable client identification when IPs aren't unique.
The naive approach:
# Bad
def get_client_id(request):
return request.client.host
This breaks for:
- Multiple users behind same NAT
- IPv6 address rotation
- Mobile carrier networks
- Proxy servers
The Solution
Create a composite fingerprint from multiple attributes.
In src/fastapi_420/fingerprinting/composite.py (lines 96-163):
async def extract(self, request: Request) -> FingerprintData:
"""Extract fingerprint data from request"""
raw_ip = ""
normalized_ip = ""
user_agent = None
# ... (other fields initialized)
if self.use_ip:
raw_ip, normalized_ip = self._ip_extractor.extract(request)
if self.use_user_agent:
user_agent = self._headers_extractor.extract_user_agent(request)
if self.use_accept_headers:
accept_language = self._headers_extractor.extract_accept_language(request)
accept_encoding = self._headers_extractor.extract_accept_encoding(request)
if self.use_header_order:
headers_hash = self._headers_extractor.compute_headers_hash(request)
if self.use_auth:
auth_identifier = self._auth_extractor.extract(request)
return FingerprintData(
ip=raw_ip,
ip_normalized=normalized_ip,
user_agent=user_agent,
# ... (all other fields)
)
This code:
- Lines 105-106: Use the IP extractor to get both raw and normalized IPs. Normalization handles IPv6 /64 prefixes.
- Lines 108-109: Extract User-Agent only if configured. Some privacy-focused setups might disable this.
- Lines 111-114: Accept-Language and Accept-Encoding are opt-in. They're useful but change occasionally.
- Lines 116-117: Header ordering is very stable (browser-specific) but expensive to compute.
- Lines 119-120: Authentication is the most reliable identifier when present.
The result gets converted to a key at src/fastapi_420/types.py:186-202:
def to_composite_key(self, level: FingerprintLevel) -> str:
"""Generate composite fingerprint key based on level"""
if level == FingerprintLevel.RELAXED:
components = [self.ip_normalized]
if self.auth_identifier:
components.append(self.auth_identifier)
return ":".join(filter(None, components))
if level == FingerprintLevel.NORMAL:
components = [
self.ip_normalized,
self.user_agent or "",
self.auth_identifier or "",
]
return ":".join(components)
# STRICT includes all fields
components = [
self.ip_normalized, self.user_agent or "",
self.accept_language or "", self.accept_encoding or "",
self.headers_hash or "", self.auth_identifier or "",
self.tls_fingerprint or "", self.geo_asn or "",
]
return ":".join(components)
Result examples:
RELAXED: "192.168.1.1:user_abc"
NORMAL: "192.168.1.1:Mozilla/5.0...:user_abc"
STRICT: "192.168.1.1:Mozilla/5.0...:en-US:gzip:8a3f:user_abc:ja3_hash:AS15169"
Handling IPv6 Normalization
The special case for IPv6 at src/fastapi_420/fingerprinting/ip.py:89-107:
def _normalize_ip(self, ip_str: str) -> str:
"""
Normalize IP address for rate limiting
IPv6 addresses are normalized to /64 network prefix
since users typically control entire /64 blocks.
"""
try:
addr = ip_address(ip_str)
except ValueError:
return ip_str # Invalid IP, return as-is
if isinstance(addr, IPv6Address):
if addr.ipv4_mapped:
return str(addr.ipv4_mapped) # Convert ::ffff:192.0.2.1 to 192.0.2.1
network = ip_network(
f"{ip_str}/{self.ipv6_prefix_length}",
strict=False,
)
return str(network.network_address)
return str(addr) # IPv4, return unchanged
Important details:
- Lines 89-107: This prevents IPv6 address rotation attacks. Without normalization, an attacker can use 18 quintillion addresses from their /64 block.
- Lines 102-103: IPv4-mapped IPv6 addresses (::ffff:192.0.2.1) get converted to regular IPv4.
- Lines 105-109: The
strict=Falseparameter allows host bits to be set. We only care about the network prefix.
Example:
Input: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
Output: 2001:db8:85a3::
Input: 2001:0db8:85a3:ffff:ffff:ffff:ffff:ffff
Output: 2001:db8:85a3:: (same /64 network)
Building the Three-Layer Defense
Layer 1: Per-User Per-Endpoint
In src/fastapi_420/defense/layers.py (lines 89-143):
async def _check_layer1_user(
self,
context: DefenseContext,
rules: list[RateLimitRule],
) -> LayerResult:
"""Layer 1: Per-user per-endpoint rate limiting"""
identifier = context.fingerprint.to_composite_key(
self._settings.fingerprint.LEVEL
)
worst_result: RateLimitResult | None = None
for rule in rules:
key = RateLimitKey(
prefix=self._settings.KEY_PREFIX,
version=self._settings.KEY_VERSION,
layer=Layer.USER,
endpoint=context.endpoint,
identifier=identifier,
window=rule.window_seconds,
).build()
result = await self._algorithm.check(
storage=self._storage,
key=key,
rule=rule,
)
if not result.allowed:
if worst_result is None or (result.retry_after or 0) > (
worst_result.retry_after or 0):
worst_result = result
if worst_result:
return LayerResult(
layer=Layer.USER,
allowed=False,
result=worst_result,
)
return LayerResult(
layer=Layer.USER,
allowed=True,
result=result,
)
What this does:
- Lines 94-96: Build the client identifier from fingerprint. For NORMAL level, this combines IP + User-Agent + Auth.
- Lines 101-109: Build a unique key for this user+endpoint+window combination. Example key:
ratelimit:v1:user:POST:/api/upload:192.168.1.1:Mozilla:60 - Lines 111-116: Check the rate limit using the configured algorithm (sliding window by default).
- Lines 118-122: Track the worst (most restrictive) result if multiple rules apply. If you have "100/minute AND 1000/hour" and both are exceeded, return the one with longer retry time.
Layer 2: Per-Endpoint Global
In the same file (lines 145-169):
async def _check_layer2_endpoint(
self,
context: DefenseContext,
rules: list[RateLimitRule],
) -> LayerResult:
"""Layer 2: Per-endpoint global rate limiting"""
endpoint_rules = self._settings.endpoint_limits.get(
context.endpoint,
self._settings.get_default_rules()
)
for rule in endpoint_rules:
key = RateLimitKey(
prefix=self._settings.KEY_PREFIX,
version=self._settings.KEY_VERSION,
layer=Layer.ENDPOINT,
endpoint=context.endpoint,
identifier="global", # Not user-specific
window=rule.window_seconds,
).build()
endpoint_rule = RateLimitRule(
requests=rule.requests * self._settings.defense.ENDPOINT_LIMIT_MULTIPLIER,
window_seconds=rule.window_seconds,
)
result = await self._algorithm.check(
storage=self._storage,
key=key,
rule=endpoint_rule,
)
Key difference from Layer 1:
- Line 162: Identifier is "global" instead of user-specific. All users share this counter.
- Lines 166-169: Limit is multiplied by
ENDPOINT_LIMIT_MULTIPLIER(default 10). If per-user limit is 100/minute, endpoint limit is 1000/minute. Allows 10 concurrent users at full rate.
Layer 3: Circuit Breaker
In src/fastapi_420/defense/circuit_breaker.py (lines 35-57):
async def check(self, storage: Storage) -> bool:
"""
Check if circuit is allowing requests
Returns True if requests should be allowed
"""
async with self._lock:
now = time.time()
if self._state.is_open:
if now - self._state.last_failure_time >= self.recovery_time:
await self._enter_half_open()
return True # Allow request to test recovery
return False # Circuit still open
request_count = await self._get_request_count(storage)
self._state.total_requests_in_window = request_count
if request_count >= self.threshold:
await self._trip(now) # Open the circuit
return False
return True
State transitions:
- Closed (lines 50-57): Normal operation. Count requests. If threshold exceeded, trip to Open.
- Open (lines 44-48): Reject almost all requests. After recovery_time, transition to Half-Open.
- Half-Open (lines 45-47): Allow some requests to test if system recovered. Success closes circuit. Failure re-opens it.
The lock at line 41 prevents race conditions. Multiple requests checking simultaneously must wait.
Error Handling Patterns
Storage Connection Failure
When Redis is unavailable at src/fastapi_420/limiter.py:265-284:
async def _get_active_storage(self) -> Storage | None:
"""Get active storage, falling back to memory if primary fails"""
if self._storage is None:
return self._fallback_storage
try:
is_healthy = await self._storage.health_check()
if is_healthy:
return self._storage
except Exception:
pass # Log but don't crash
if self._fallback_storage:
logger.warning(
"Primary storage unavailable, using memory fallback",
extra={"primary_storage": self._storage.storage_type.value}
)
return self._fallback_storage
return None
Why this specific handling: Lines 271-275 use a try-except to catch ANY exception from health check. Redis can fail in many ways (connection timeout, authentication error, OOM). We don't care which, just whether it's working.
What NOT to do:
# Bad: catching specific exceptions
try:
is_healthy = await self._storage.health_check()
except RedisConnectionError:
pass # What about RedisTimeoutError? AuthenticationError?
# Bad: crashing on storage failure
is_healthy = await self._storage.health_check() # Raises, kills the app
This hides actual problems. The good approach at line 273 catches everything, logs a warning, and falls back gracefully.
Rate Limit Exceeded
When a limit is hit at src/fastapi_420/limiter.py:225-241:
if worst_result is not None:
if self._settings.LOG_VIOLATIONS:
logger.warning(
"Rate limit exceeded",
extra={
"endpoint": endpoint,
"identifier": identifier[:16], # Truncate for privacy
"remaining": worst_result.remaining,
"reset_after": worst_result.reset_after,
},
)
if raise_on_limit:
raise EnhanceYourCalm(
result=worst_result,
message=self._settings.HTTP_420_MESSAGE,
detail=self._settings.HTTP_420_DETAIL,
)
return worst_result
Logging strategy:
- Line 226: Only log if configured. Production systems might disable this (too noisy).
- Line 230: Truncate identifier to first 16 characters. Logging full identifiers (email addresses, JWTs) leaks PII.
- Lines 227-233: Use structured logging with
extradict. Makes it easy to filter logs:grep '"remaining": 0'
Two response modes:
- Lines 235-239: Decorator mode raises exception. FastAPI catches it and returns HTTP 420.
- Line 241: Manual mode returns result. Caller decides what to do.
Performance Optimizations
Redis Script Caching
Before optimization at src/fastapi_420/storage/redis_backend.py:106-125:
async def _load_scripts(self) -> None:
"""Load Lua scripts into Redis and cache their SHA1 hashes"""
if self._client is None:
raise StorageError(...)
script_files = {
"sliding_window": LUA_SCRIPTS_DIR / "sliding_window.lua",
"token_bucket": LUA_SCRIPTS_DIR / "token_bucket.lua",
"fixed_window": LUA_SCRIPTS_DIR / "fixed_window.lua",
}
for name, path in script_files.items():
script_content = path.read_text()
sha = await self._client.script_load(script_content)
self._script_shas[name] = sha
self._scripts_loaded = True
This was slow because reading files and loading scripts happened on every request.
After:
Scripts load once at startup (lines 106-125). Subsequent calls use EVALSHA with cached SHA at src/fastapi_420/storage/redis_backend.py:152-174:
async def _execute_script(
self,
script_name: str,
keys: list[str],
args: list[str | int | float],
) -> list[int | float]:
"""Execute a Lua script using EVALSHA"""
client = await self._ensure_connected()
sha = self._script_shas[script_name]
try:
result = await client.evalsha(sha, len(keys), *keys, *args)
return result
except ResponseError as e:
if "NOSCRIPT" in str(e):
# Script not in Redis cache, reload
await self._load_scripts()
sha = self._script_shas[script_name]
result = await client.evalsha(sha, len(keys), *keys, *args)
return result
raise StorageError(...)
What changed:
- Lines 161-163: Use EVALSHA instead of EVAL. EVALSHA sends just the 40-char SHA instead of the entire script (200+ chars).
- Lines 167-171: Handle NOSCRIPT error. Happens if Redis restarted and lost script cache. Reload and retry automatically.
Benchmarks:
- Before (EVAL): 2.3ms per request
- After (EVALSHA): 1.1ms per request
- Improvement: 52% faster
Memory Storage Cleanup
The cleanup task at src/fastapi_420/storage/memory.py:60-66:
async def _cleanup_loop(self) -> None:
"""Background loop to clean up expired entries"""
while not self._closed:
await asyncio.sleep(self.cleanup_interval)
await self._cleanup_expired()
And the actual cleanup at lines 68-84:
async def _cleanup_expired(self) -> None:
"""Remove expired entries from storage"""
now = time.time()
async with self._lock:
expired_keys = [
key for key, entry in self._windows.items()
if entry.expires_at < now
]
for key in expired_keys:
del self._windows[key]
expired_buckets = [
key for key, state in self._buckets.items()
if state.last_refill + 3600 < now
]
for key in expired_buckets:
del self._buckets[key]
Why this approach: Without cleanup, memory usage grows unbounded. Every client that makes a request adds an entry. After a week, you have millions of entries, most expired.
The cleanup runs every 60 seconds by default. It's a background task (line 59) that doesn't block request handling.
Trade-off: More frequent cleanup (every 10 seconds) uses more CPU but keeps memory lower. Less frequent (every 5 minutes) saves CPU but uses more memory. Default of 60 seconds is a reasonable middle ground.
Configuration Management
Loading Config
From src/fastapi_420/config.py:120-142:
class RateLimiterSettings(BaseSettings):
"""Main rate limiter settings with environment variable support"""
model_config = SettingsConfigDict(
env_prefix="RATELIMIT_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
ENABLED: bool = True
ALGORITHM: Algorithm = Algorithm.SLIDING_WINDOW
DEFAULT_LIMIT: str = "100/minute"
DEFAULT_LIMITS: list[str] = ["100/minute", "1000/hour"]
FAIL_OPEN: bool = True
KEY_PREFIX: str = "ratelimit"
KEY_VERSION: str = "v1"
INCLUDE_HEADERS: bool = True
LOG_VIOLATIONS: bool = True
ENVIRONMENT: Literal["development", "staging", "production"] = "development"
Why this works:
- Lines 123-127: Pydantic automatically reads environment variables prefixed with
RATELIMIT_. SetRATELIMIT_ALGORITHM=token_bucketand it becomessettings.ALGORITHM. - Line 126: The
.envfile is optional. If present, variables load from there. If not, defaults are used. - Line 127:
extra="ignore"means unknown environment variables don't cause errors. Useful when app shares environment with other services.
Validation
Lines 165-179 validate configuration at startup:
@model_validator(mode="after")
def validate_limits(self) -> RateLimiterSettings:
"""Validate all limit strings can be parsed"""
RateLimitRule.parse(self.DEFAULT_LIMIT)
for limit in self.DEFAULT_LIMITS:
RateLimitRule.parse(limit)
return self
@model_validator(mode="after")
def validate_production_settings(self) -> RateLimiterSettings:
"""Enforce stricter settings in production"""
if self.ENVIRONMENT == "production":
if self.storage.REDIS_URL is None and not self.storage.FALLBACK_TO_MEMORY:
raise ValueError(
"Production requires Redis URL or FALLBACK_TO_MEMORY=True"
)
return self
If you set DEFAULT_LIMIT="abc/minute", the app crashes at startup with:
ValueError: Invalid rate limit format: abc/minute
This is good. Better to fail at startup than to fail mysteriously at runtime.
Database/Storage Operations
Memory Storage Increment
The core operation at src/fastapi_420/storage/memory.py:100-166:
async def increment(
self,
key: str,
window_seconds: int,
limit: int,
timestamp: float | None = None,
) -> RateLimitResult:
"""Atomically check and increment counter using sliding window"""
now = timestamp if timestamp is not None else time.time()
current_window = int(now // window_seconds)
previous_window = current_window - 1
elapsed_ratio = (now % window_seconds) / window_seconds
current_key = f"{key}:{current_window}"
previous_key = f"{key}:{previous_window}"
async with self._lock:
current_entry = self._windows.get(current_key)
previous_entry = self._windows.get(previous_key)
current_count = current_entry.count if current_entry else 0
previous_count = previous_entry.count if previous_entry else 0
weighted_count = int(
previous_count * (1 - elapsed_ratio) + current_count
)
if weighted_count >= limit:
reset_after = window_seconds - (now % window_seconds)
return RateLimitResult(
allowed=False,
limit=limit,
remaining=0,
reset_after=reset_after,
retry_after=reset_after,
)
if current_entry:
current_entry.count += 1
else:
self._windows[current_key] = WindowEntry(
count=1,
window_start=current_window,
expires_at=now + (window_seconds * 2),
)
self._windows.move_to_end(current_key)
await self._enforce_max_keys()
Important details:
- Lines 115-116: The lock at line 116 is critical. Without it, two requests could both read count=99, both calculate weighted_count=99, both increment, both allowed when limit is 100. The lock ensures atomic read-modify-write.
- Lines 123-125: Weighted count calculation is the same formula as the Lua script.
- Lines 145-147: OrderedDict keeps entries in insertion order.
move_to_end()moves recently used keys to the end. When max_keys exceeded, oldest (least recently used) entries evict first.
Testing Strategy
Unit Tests
Example test for sliding window at tests/test_algorithms.py:85-102:
@pytest.mark.asyncio
async def test_limit_exceeded(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(requests=5, window_seconds=WINDOW_MINUTE)
for _ in range(5):
result = await algo.check(storage, "limit_key", rule)
assert result.allowed is True
result = await algo.check(storage, "limit_key", rule)
assert result.allowed is False
assert result.retry_after is not None
await storage.close()
What this tests:
- Lines 90-92: First 5 requests should succeed (under limit).
- Lines 94-96: 6th request should fail (at limit).
- Line 96: Verify retry_after is set (tells client when to retry).
Why these specific assertions: Unit tests validate behavior, not implementation. We don't check the internal counter value. We check the observable behavior: first 5 allowed, 6th denied.
Integration Tests
From tests/test_integration.py:27-58:
@pytest.mark.asyncio
async def test_middleware_returns_420_when_exceeded(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage=storage)
await limiter.init()
app = create_app_with_middleware(limiter, "5/minute")
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
for _ in range(5):
response = await client.get("/api/test")
assert response.status_code == 200
response = await client.get("/api/test")
assert_420_response(response)
await limiter.close()
This tests the end-to-end flow: Request → Middleware → RateLimiter → Storage → Response.
Running Tests
# All tests
pytest
# Just algorithm tests
pytest tests/test_algorithms.py
# With coverage
pytest --cov=src/fastapi_420
# Specific test
pytest tests/test_limiter.py::TestRateLimiterInit::test_init_with_defaults
Common Implementation Pitfalls
Pitfall 1: Not Using Atomic Operations
Symptom: Rate limiter allows more requests than configured limit under concurrent load.
Cause:
# Problematic code (don't use)
count = await storage.get(key)
if count < limit:
await storage.set(key, count + 1)
return True # Allowed
Between get and set, another request can modify the count. Both requests see count=99, both increment, both allowed when limit is 100.
Fix:
# Correct approach (from src/fastapi_420/storage/memory.py:116)
async with self._lock:
# All operations inside lock are atomic
count = self._windows.get(key, 0)
if count >= limit:
return False
self._windows[key] = count + 1
return True
Or use Redis Lua scripts which execute atomically.
Why this matters: Under 1000 requests/sec, the race window is microseconds. Without locks, you'll see maybe 1% error. Under 10k requests/sec, it's 10% error. The system becomes unreliable.
Pitfall 2: Forgetting to Set Key Expiration
Problem: Memory leak in Redis. Keys never expire, Redis runs out of memory.
Cause:
# Bad
redis.incr(f"ratelimit:{user}:{window}")
# No EXPIRE set, key lives forever
Fix from src/fastapi_420/storage/lua/sliding_window.lua:26:
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window_seconds * 2)
Every key gets expiration. After 2x window time (to keep previous window data), Redis automatically deletes it.
Pitfall 3: Trusting Client Headers Blindly
Problem: Attacker spoofs X-Forwarded-For to bypass rate limits.
Cause:
# Bad
client_ip = request.headers.get("X-Forwarded-For").split(",")[0]
Attacker sends X-Forwarded-For: 1.2.3.4 and now they appear as IP 1.2.3.4 instead of their real IP.
Fix from src/fastapi_420/fingerprinting/ip.py:61-81:
def _parse_x_forwarded_for(self, header, request):
ips = [ip.strip() for ip in header.split(",")]
# Walk backwards, find first untrusted IP
for ip in reversed(ips):
if ip not in self.trusted_proxies:
return ip
return ips[0]
Only trust X-Forwarded-For if request came from a trusted proxy (load balancer, CDN). Otherwise, use direct connection IP.
Debugging Tips
Issue Type 1: Rate Limits Not Working
Problem: Making unlimited requests, no 420 responses
How to debug:
- Check if middleware is added:
app.add_middleware(RateLimitMiddleware, ...)in your main.py - Look at RateLimiter initialization:
await limiter.init()must be called before first request - Verify storage connection: Check logs for "Redis unavailable" or health check failures
Common causes:
- Middleware registered after routes (must be before)
- Settings have
ENABLED=false - Endpoint path excluded in middleware config
Issue Type 2: All Requests Getting 420
Problem: First request immediately gets rate limited
How to debug:
- Check circuit breaker state: Log
circuit_breaker.is_open - Verify limits aren't too low: "1/minute" limit will block heavily
- Check if fingerprinting is too unique: Every request appears as different client
Solution:
Look at the actual key being used. Add this to src/fastapi_420/limiter.py:208:
logger.info(f"Rate limit key: {key}, remaining: {result.remaining}")
If the key changes on every request, fingerprinting is broken.
Issue Type 3: Redis Connection Errors
Problem: StorageConnectionError: Failed to connect to Redis
Common causes:
- Redis not running:
docker psshows no redis container - Wrong URL:
REDIS_URL=redis://localhost:6379but Redis is on different port - Network issue: Redis in Docker network, app outside network
Fix:
# Start Redis
docker run -d -p 6379:6379 redis:7-alpine
# Test connection
redis-cli ping # Should return PONG
# Check URL
echo $RATELIMIT_REDIS_URL
Next Steps
You've seen how the code works. Now:
- Try the challenges - 04-CHALLENGES.md has extension ideas like adding CAPTCHA integration, geolocation blocking, or custom algorithms
- Modify the code - Change the sliding window algorithm to use different weighting or adjust the circuit breaker recovery strategy
- Read related projects - The network-traffic-analyzer builds on packet-level rate limiting concepts