Create 02-ARCHITECTURE.md
This commit is contained in:
parent
531d8694fd
commit
54864dcfdb
|
|
@ -0,0 +1,713 @@
|
||||||
|
# System Architecture
|
||||||
|
|
||||||
|
This document breaks down how the system is designed and why certain architectural decisions were made.
|
||||||
|
|
||||||
|
## High Level Architecture
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI App │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ RateLimitMiddleware │
|
||||||
|
│ (ASGI request interceptor) │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ RateLimiter │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Algorithm │ │Fingerprinter │ │ Storage │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ LayeredDefense │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
|
||||||
|
│ │Layer 1: │→ │Layer 2: │→ │Layer 3: │ │
|
||||||
|
│ │Per-User │ │Endpoint │ │Circuit Breaker │ │
|
||||||
|
│ └──────────┘ └──────────┘ └────────────────┘ │
|
||||||
|
└───────────────────────┬─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Storage Backend (Redis/Memory) │
|
||||||
|
│ Atomic counters + Lua scripts │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Breakdown
|
||||||
|
|
||||||
|
**RateLimitMiddleware** (`src/fastapi_420/middleware.py`)
|
||||||
|
- Purpose: Intercept every HTTP request before it reaches route handlers
|
||||||
|
- Responsibilities: Apply default rate limits, exclude health check endpoints, add rate limit headers to responses
|
||||||
|
- Interfaces: ASGI middleware protocol, receives Request and call_next, returns Response
|
||||||
|
|
||||||
|
**RateLimiter** (`src/fastapi_420/limiter.py`)
|
||||||
|
- Purpose: Core orchestrator that coordinates all rate limiting logic
|
||||||
|
- Responsibilities: Initialize storage and algorithms, extract fingerprints, check limits, handle errors with fail-open behavior
|
||||||
|
- Interfaces: Can be used as decorator `@limiter.limit()`, dependency injection `Depends(RateLimitDep)`, or called directly `await limiter.check()`
|
||||||
|
|
||||||
|
**CompositeFingerprinter** (`src/fastapi_420/fingerprinting/composite.py`)
|
||||||
|
- Purpose: Identify clients reliably across multiple attributes
|
||||||
|
- Responsibilities: Combine IP, User-Agent, auth tokens, TLS fingerprints into unique identifier
|
||||||
|
- Interfaces: `async extract(request) -> FingerprintData`, configurable via FingerprintLevel presets
|
||||||
|
|
||||||
|
**LayeredDefense** (`src/fastapi_420/defense/layers.py`)
|
||||||
|
- Purpose: Three-layer protection against different attack types
|
||||||
|
- Responsibilities: Check per-user limits, per-endpoint limits, global circuit breaker in sequence
|
||||||
|
- Interfaces: `async check_all_layers()` returns RateLimitResult or raises EnhanceYourCalm
|
||||||
|
|
||||||
|
**Storage** (Redis: `src/fastapi_420/storage/redis_backend.py`, Memory: `src/fastapi_420/storage/memory.py`)
|
||||||
|
- Purpose: Atomic counter operations with window/bucket state management
|
||||||
|
- Responsibilities: Increment counters atomically, get current state, handle expiration, health checks
|
||||||
|
- Interfaces: Protocol defined in `src/fastapi_420/types.py:371-429` with methods like `increment()`, `consume_token()`, `get_window_state()`
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Primary Use Case Flow: Request Rate Limiting
|
||||||
|
|
||||||
|
Step by step walkthrough of what happens when a request hits the API:
|
||||||
|
```
|
||||||
|
1. Request arrives → ASGI Middleware (middleware.py:65)
|
||||||
|
FastAPI receives request, passes to RateLimitMiddleware
|
||||||
|
Middleware checks if path is excluded (health endpoints)
|
||||||
|
|
||||||
|
2. Middleware → RateLimiter.check() (limiter.py:181)
|
||||||
|
Determines rate limit rules for this endpoint
|
||||||
|
Calls limiter with request object and rules
|
||||||
|
|
||||||
|
3. RateLimiter → Fingerprinter (limiter.py:198)
|
||||||
|
Extracts client fingerprint from request
|
||||||
|
Returns FingerprintData with IP, User-Agent, auth, etc.
|
||||||
|
|
||||||
|
4. RateLimiter → LayeredDefense (if using defense system)
|
||||||
|
OR directly to Algorithm.check() for simple cases
|
||||||
|
Passes fingerprint and rules to defense layers
|
||||||
|
|
||||||
|
5. LayeredDefense → Storage (layers.py:89-143)
|
||||||
|
Layer 1: Check per-user limit (key: user:endpoint:window)
|
||||||
|
Layer 2: Check per-endpoint limit (key: endpoint:global:window)
|
||||||
|
Layer 3: Check circuit breaker (global counter)
|
||||||
|
|
||||||
|
6. Storage → Redis/Memory Lua Script (lua/sliding_window.lua:1-32)
|
||||||
|
Execute atomic increment-and-check operation
|
||||||
|
Return {allowed, remaining, reset_after}
|
||||||
|
|
||||||
|
7. Result propagates back → Middleware (middleware.py:91)
|
||||||
|
If allowed: Add RateLimit-* headers, pass to route handler
|
||||||
|
If denied: Return HTTP 420 with Retry-After header
|
||||||
|
```
|
||||||
|
|
||||||
|
Example with code references:
|
||||||
|
```
|
||||||
|
1. Request → middleware.py:65-75
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
if not await self._should_limit(request):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
2. Middleware → limiter.py:181-199
|
||||||
|
result = await self.limiter.check(
|
||||||
|
request, limit, key_func=self.key_func, raise_on_limit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
3. Fingerprinter → fingerprinting/composite.py:96-125
|
||||||
|
fingerprint = await self._fingerprinter.extract(request)
|
||||||
|
|
||||||
|
4. Algorithm → algorithms/sliding_window.py:31-43
|
||||||
|
result = await storage.increment(
|
||||||
|
key=key, window_seconds=rule.window_seconds, limit=rule.requests
|
||||||
|
)
|
||||||
|
|
||||||
|
5. Storage → storage/redis_backend.py:236-258
|
||||||
|
result = await self._execute_script(
|
||||||
|
"sliding_window", keys=[key], args=[window_seconds, limit, now]
|
||||||
|
)
|
||||||
|
|
||||||
|
6. Response → middleware.py:91-101
|
||||||
|
if not result.allowed:
|
||||||
|
exc = EnhanceYourCalm(result=result)
|
||||||
|
return self._create_420_response(exc)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circuit Breaker Flow
|
||||||
|
|
||||||
|
When the system detects abnormal load:
|
||||||
|
```
|
||||||
|
1. Every request → CircuitBreaker.record_request() (circuit_breaker.py:57)
|
||||||
|
Increment global counter: circuit:global:requests:{window}
|
||||||
|
|
||||||
|
2. CircuitBreaker.check() → Get request count (circuit_breaker.py:45)
|
||||||
|
Calculate total requests in current window
|
||||||
|
Compare to threshold (default: 10,000/minute)
|
||||||
|
|
||||||
|
3. If threshold exceeded → Trip circuit (circuit_breaker.py:76)
|
||||||
|
Set is_open = True
|
||||||
|
Record failure_time
|
||||||
|
Log warning with request count
|
||||||
|
|
||||||
|
4. Subsequent requests → Check bypass rules (layers.py:232-252)
|
||||||
|
Mode: ADAPTIVE → Allow authenticated users
|
||||||
|
Mode: LOCKDOWN → Block almost everything
|
||||||
|
Mode: DISABLED → No circuit breaker
|
||||||
|
|
||||||
|
5. After recovery_time (default: 30s) → Half-open state (circuit_breaker.py:90)
|
||||||
|
Allow limited traffic to test recovery
|
||||||
|
If successful, close circuit
|
||||||
|
If still overloaded, re-open
|
||||||
|
|
||||||
|
6. Circuit closes → Normal operation resumes
|
||||||
|
Reset failure_count, clear is_open flag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Patterns
|
||||||
|
|
||||||
|
### Factory Pattern for Algorithms and Storage
|
||||||
|
|
||||||
|
**What it is:**
|
||||||
|
Create objects without specifying exact class. Client code asks for "sliding window algorithm" and factory returns the correct instance.
|
||||||
|
|
||||||
|
**Where we use it:**
|
||||||
|
- Algorithm factory: `src/fastapi_420/algorithms/__init__.py:11-28`
|
||||||
|
- Storage factory: `src/fastapi_420/storage/__init__.py:16-21`
|
||||||
|
|
||||||
|
**Why we chose it:**
|
||||||
|
Runtime configuration. Users set `ALGORITHM=sliding_window` in environment variables. The factory picks the right class at startup rather than compile time. Makes testing easier too, you can mock the factory to return test implementations.
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Pros: Loose coupling, easy to add new algorithms without changing existing code
|
||||||
|
- Cons: Extra indirection layer, can make stack traces harder to follow
|
||||||
|
|
||||||
|
Example implementation from `src/fastapi_420/algorithms/__init__.py:11-28`:
|
||||||
|
```python
|
||||||
|
def create_algorithm(algorithm_type: Algorithm) -> BaseAlgorithm:
|
||||||
|
algorithm_map: dict[Algorithm, type[BaseAlgorithm]] = {
|
||||||
|
Algorithm.SLIDING_WINDOW: SlidingWindowAlgorithm,
|
||||||
|
Algorithm.TOKEN_BUCKET: TokenBucketAlgorithm,
|
||||||
|
Algorithm.FIXED_WINDOW: FixedWindowAlgorithm,
|
||||||
|
Algorithm.LEAKY_BUCKET: SlidingWindowAlgorithm, # Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
algorithm_class = algorithm_map.get(
|
||||||
|
algorithm_type, SlidingWindowAlgorithm # Safe default
|
||||||
|
)
|
||||||
|
return algorithm_class()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strategy Pattern for Algorithms
|
||||||
|
|
||||||
|
**What it is:**
|
||||||
|
Define a family of interchangeable algorithms. All implement the same interface but with different behavior.
|
||||||
|
|
||||||
|
**Where we use it:**
|
||||||
|
All algorithms inherit from `BaseAlgorithm` (`src/fastapi_420/algorithms/base.py:17-49`) and implement `check()` and `get_current_usage()`.
|
||||||
|
|
||||||
|
**Why we chose it:**
|
||||||
|
Allows swapping rate limiting algorithms at runtime without changing the RateLimiter code. Want to switch from sliding window to token bucket? Just change one config value.
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Pros: Clean separation of concerns, algorithms are independently testable
|
||||||
|
- Cons: Can't optimize for algorithm-specific features, must fit common interface
|
||||||
|
|
||||||
|
### Dependency Injection for Storage and Settings
|
||||||
|
|
||||||
|
**What it is:**
|
||||||
|
Instead of classes creating their dependencies (`storage = RedisStorage()`), they receive them as constructor arguments.
|
||||||
|
|
||||||
|
**Where we use it:**
|
||||||
|
`RateLimiter.__init__(settings, storage)` at `src/fastapi_420/limiter.py:74-80`
|
||||||
|
|
||||||
|
**Why we chose it:**
|
||||||
|
Testing and flexibility. In production, inject RedisStorage. In tests, inject MemoryStorage. In edge cases, inject a mock. The RateLimiter doesn't care what storage implementation it gets as long as it implements the protocol.
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Pros: Testability, flexibility, explicit dependencies
|
||||||
|
- Cons: More verbose initialization, dependency management complexity
|
||||||
|
|
||||||
|
Example from `src/fastapi_420/limiter.py:74-80`:
|
||||||
|
```python
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: RateLimiterSettings | None = None,
|
||||||
|
storage: Storage | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings or get_settings()
|
||||||
|
self._storage = storage # Injected, not created
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer Separation
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 1: Application (FastAPI Routes) │
|
||||||
|
│ - Defines endpoints and business logic │
|
||||||
|
│ - Doesn't know about rate limiting internals │
|
||||||
|
└────────────────────────┬───────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 2: Rate Limiting Logic │
|
||||||
|
│ - RateLimiter, LayeredDefense, Algorithms │
|
||||||
|
│ - Doesn't know about HTTP details │
|
||||||
|
└────────────────────────┬───────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 3: Storage Abstraction │
|
||||||
|
│ - Storage protocol, Redis/Memory implementations │
|
||||||
|
│ - Doesn't know about rate limiting concepts │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Layers?
|
||||||
|
|
||||||
|
Separation allows independent evolution. You can:
|
||||||
|
- Swap storage backends without touching rate limit logic
|
||||||
|
- Change algorithms without modifying HTTP handling
|
||||||
|
- Test each layer in isolation
|
||||||
|
|
||||||
|
In 2019, GitHub migrated from MySQL to a custom storage system for rate limiting. Because their rate limit logic was separate from storage, the migration took weeks instead of months.
|
||||||
|
|
||||||
|
### What Lives Where
|
||||||
|
|
||||||
|
**Layer 1 (Application):**
|
||||||
|
- Files: `examples/app.py`, user route handlers
|
||||||
|
- Imports: FastAPI, depends on Layer 2 via middleware or dependencies
|
||||||
|
- Forbidden: Direct storage access, algorithm selection
|
||||||
|
|
||||||
|
**Layer 2 (Rate Limiting Logic):**
|
||||||
|
- Files: `limiter.py`, `algorithms/`, `defense/`, `fingerprinting/`
|
||||||
|
- Imports: Storage protocol, Pydantic models, async utilities
|
||||||
|
- Forbidden: HTTP-specific code (Request/Response), storage implementation details
|
||||||
|
|
||||||
|
**Layer 3 (Storage):**
|
||||||
|
- Files: `storage/memory.py`, `storage/redis_backend.py`, `storage/lua/*.lua`
|
||||||
|
- Imports: Redis client, asyncio, dataclasses
|
||||||
|
- Forbidden: Rate limiting concepts (what a "limit" or "window" means), HTTP details
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### RateLimitRule
|
||||||
|
|
||||||
|
From `src/fastapi_420/types.py:96-155`:
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RateLimitRule:
|
||||||
|
requests: int
|
||||||
|
window_seconds: int
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields explained:**
|
||||||
|
- `requests`: Maximum number of requests allowed in the window. Must be positive integer. Setting this too low (like 1/minute) makes APIs unusable. Too high defeats the purpose.
|
||||||
|
- `window_seconds`: Time window in seconds. Common values: 1 (per second), 60 (per minute), 3600 (per hour), 86400 (per day). Must be positive.
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Multiple rules can apply to one endpoint (example: "100/minute AND 1000/hour")
|
||||||
|
- Rules are parsed from strings like "100/minute" via `RateLimitRule.parse()` at line 119-149
|
||||||
|
- Algorithms use the window_seconds to calculate which time bucket to check
|
||||||
|
|
||||||
|
### FingerprintData
|
||||||
|
|
||||||
|
From `src/fastapi_420/types.py:158-202`:
|
||||||
|
```python
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FingerprintData:
|
||||||
|
ip: str
|
||||||
|
ip_normalized: str
|
||||||
|
user_agent: str | None = None
|
||||||
|
accept_language: str | None = None
|
||||||
|
accept_encoding: str | None = None
|
||||||
|
headers_hash: str | None = None
|
||||||
|
auth_identifier: str | None = None
|
||||||
|
tls_fingerprint: str | None = None
|
||||||
|
geo_asn: str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fields explained:**
|
||||||
|
- `ip`: Raw IP address from request, as seen by the server (might be proxy IP)
|
||||||
|
- `ip_normalized`: Processed IP for rate limiting. For IPv6, this is the /64 network prefix. For IPv4, usually same as raw IP.
|
||||||
|
- `user_agent`: Browser identification string. Used for fingerprinting but not alone (easily spoofed).
|
||||||
|
- `auth_identifier`: User ID from JWT token, API key, or session cookie. Most reliable identifier when present, hashed by default for privacy.
|
||||||
|
- `headers_hash`: SHA256 hash of header ordering and values. Browsers send headers in consistent order, bots often don't. 16-character hex string.
|
||||||
|
- `tls_fingerprint`: JA3 hash of TLS handshake parameters. Requires proxy to populate X-JA3-Fingerprint header.
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Produced by `CompositeFingerprinter.extract()` at `src/fastapi_420/fingerprinting/composite.py:96-163`
|
||||||
|
- Converted to rate limit key via `to_composite_key()` at lines 186-202
|
||||||
|
- Different FingerprintLevels include different fields in the key
|
||||||
|
|
||||||
|
### RateLimitResult
|
||||||
|
|
||||||
|
From `src/fastapi_420/types.py:66-92`:
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RateLimitResult:
|
||||||
|
allowed: bool
|
||||||
|
limit: int
|
||||||
|
remaining: int
|
||||||
|
reset_after: float
|
||||||
|
retry_after: float | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why frozen and slots:**
|
||||||
|
Results are immutable once created (frozen=True prevents modification). Slots reduce memory overhead by avoiding `__dict__` attribute, important when handling thousands of requests per second.
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
What we're protecting against:
|
||||||
|
1. **Brute force authentication attacks** - Attacker tries millions of passwords on `/login`. Rate limit of 3-5/minute blocks this completely.
|
||||||
|
2. **API scraping and data harvesting** - Competitor tries to download your entire product catalog. Rate limiting prevents bulk extraction.
|
||||||
|
3. **Resource exhaustion DoS** - Attacker floods expensive endpoints (ML inference, report generation) to consume CPU/memory.
|
||||||
|
|
||||||
|
What we're NOT protecting against (out of scope):
|
||||||
|
- **Network layer DDoS** - Use Cloudflare or AWS Shield for volumetric attacks (100+ Gbps). Application-layer rate limiting can't handle this volume.
|
||||||
|
- **Sophisticated bot farms** - If attackers control thousands of residential IPs with real browsers, rate limiting alone won't stop them. Need CAPTCHA or behavioral analysis.
|
||||||
|
- **Internal threats** - Authenticated users with valid credentials who abuse APIs. Requires different monitoring and response.
|
||||||
|
|
||||||
|
### Defense Layers
|
||||||
|
|
||||||
|
From `src/fastapi_420/defense/layers.py:47-84`:
|
||||||
|
```
|
||||||
|
Layer 1: Per-User Per-Endpoint (most specific)
|
||||||
|
↓
|
||||||
|
Checks: user_abc:POST:/api/upload:60s
|
||||||
|
Purpose: Stop individual user abuse
|
||||||
|
Example: User makes 100 requests/sec to upload endpoint
|
||||||
|
↓
|
||||||
|
Layer 2: Per-Endpoint Global (endpoint protection)
|
||||||
|
↓
|
||||||
|
Checks: global:POST:/api/upload:60s
|
||||||
|
Purpose: Prevent endpoint overload from distributed sources
|
||||||
|
Example: 1000 different users each making 10 requests/sec
|
||||||
|
↓
|
||||||
|
Layer 3: Circuit Breaker (DDoS protection)
|
||||||
|
↓
|
||||||
|
Checks: circuit:global:requests:60s
|
||||||
|
Purpose: Protect entire API when under massive attack
|
||||||
|
Example: 10 million requests/minute from botnet
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why multiple layers?**
|
||||||
|
Each layer addresses different attack patterns. In 2018, Fortnite's login servers went down despite rate limiting because they only had per-user limits. When 10 million players tried to log in simultaneously (legitimate traffic spike), the aggregate exceeded capacity. Per-endpoint global limits would have throttled the traffic to sustainable levels.
|
||||||
|
|
||||||
|
## Storage Strategy
|
||||||
|
|
||||||
|
### Memory Storage (Single Instance)
|
||||||
|
|
||||||
|
**What we store:**
|
||||||
|
- Window counters: `Dict[str, WindowEntry]` where key is "ratelimit:v1:user:endpoint:identifier:window_id"
|
||||||
|
- Token bucket states: `Dict[str, TokenBucketState]` keyed by identifier
|
||||||
|
- Stored in OrderedDict for LRU eviction when max_keys exceeded
|
||||||
|
|
||||||
|
**Why this storage:**
|
||||||
|
Simplicity and speed. In-memory access is microseconds vs Redis milliseconds. Perfect for development, testing, or single-instance APIs that don't need distributed state.
|
||||||
|
|
||||||
|
**Schema design from** `src/fastapi_420/storage/memory.py:18-25`:
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class WindowEntry:
|
||||||
|
count: int = 0 # Requests in this window
|
||||||
|
window_start: int = 0 # Unix timestamp / window_seconds
|
||||||
|
expires_at: float = 0.0 # When to delete this entry
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- Lost on restart (no persistence)
|
||||||
|
- Doesn't scale horizontally (can't share state between servers)
|
||||||
|
- Max memory usage: `max_keys * ~100 bytes` = 10MB for 100k keys
|
||||||
|
|
||||||
|
### Redis Storage (Distributed)
|
||||||
|
|
||||||
|
**What we store:**
|
||||||
|
- Sliding window: Two keys per client per window: `key:current_window` and `key:previous_window`
|
||||||
|
- Token bucket: Hash with fields {tokens, last_refill, capacity, refill_rate}
|
||||||
|
- Lua scripts loaded once at startup, executed via EVALSHA
|
||||||
|
|
||||||
|
**Why this storage:**
|
||||||
|
Production deployments run multiple API servers behind a load balancer. They need shared rate limit state. If Server A allows 50 requests and Server B allows 50, that's 100 total when the limit is 100. Redis provides the single source of truth.
|
||||||
|
|
||||||
|
**Schema design from** `src/fastapi_420/storage/lua/sliding_window.lua:13-20`:
|
||||||
|
```lua
|
||||||
|
local current_key = key .. ":" .. current_window
|
||||||
|
local previous_key = key .. ":" .. previous_window
|
||||||
|
|
||||||
|
local current_count = redis.call('GET', current_key) or 0
|
||||||
|
local previous_count = redis.call('GET', previous_key) or 0
|
||||||
|
|
||||||
|
-- Keys auto-expire after 2 windows to prevent memory leaks
|
||||||
|
redis.call('EXPIRE', current_key, window_seconds * 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance characteristics:**
|
||||||
|
- Latency: 1-5ms for local Redis, 10-50ms for remote
|
||||||
|
- Throughput: 100k+ operations/sec per Redis instance
|
||||||
|
- Memory: ~100 bytes per active key, 1M keys = 100MB
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
From `src/fastapi_420/config.py:120-164`:
|
||||||
|
```bash
|
||||||
|
RATELIMIT_ENABLED=true # Master switch, disable for testing
|
||||||
|
RATELIMIT_ALGORITHM=sliding_window # sliding_window|token_bucket|fixed_window
|
||||||
|
RATELIMIT_DEFAULT_LIMIT=100/minute # Fallback when no specific limit set
|
||||||
|
RATELIMIT_FAIL_OPEN=true # Allow requests if storage fails
|
||||||
|
RATELIMIT_KEY_PREFIX=ratelimit # Namespace for Redis keys
|
||||||
|
RATELIMIT_INCLUDE_HEADERS=true # Add RateLimit-* headers to responses
|
||||||
|
RATELIMIT_LOG_VIOLATIONS=true # Log when limits exceeded
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
RATELIMIT_REDIS_URL=redis://localhost:6379/0 # If set, use Redis; else memory
|
||||||
|
RATELIMIT_REDIS_MAX_CONNECTIONS=100 # Connection pool size
|
||||||
|
RATELIMIT_MEMORY_MAX_KEYS=100000 # LRU eviction threshold
|
||||||
|
|
||||||
|
# Fingerprinting
|
||||||
|
RATELIMIT_FP_LEVEL=normal # strict|normal|relaxed|custom
|
||||||
|
RATELIMIT_FP_USE_IP=true
|
||||||
|
RATELIMIT_FP_USE_USER_AGENT=true
|
||||||
|
RATELIMIT_FP_TRUST_X_FORWARDED_FOR=false # Enable behind proxies
|
||||||
|
|
||||||
|
# Defense
|
||||||
|
RATELIMIT_DEFENSE_MODE=adaptive # adaptive|lockdown|disabled
|
||||||
|
RATELIMIT_DEFENSE_GLOBAL_LIMIT=50000/minute
|
||||||
|
RATELIMIT_DEFENSE_CIRCUIT_THRESHOLD=10000 # Requests/minute to trip circuit
|
||||||
|
RATELIMIT_DEFENSE_CIRCUIT_RECOVERY_TIME=30 # Seconds before retry
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Strategy
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
Use defaults with memory storage. Override via `.env` file in project root. Settings loaded at `src/fastapi_420/config.py:183-188`:
|
||||||
|
```python
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> RateLimiterSettings:
|
||||||
|
return RateLimiterSettings() # Auto-loads from .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
Set environment variables in container orchestration (Kubernetes ConfigMap, Docker Compose, systemd). The `@lru_cache` decorator ensures settings load only once per process.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
Pydantic validates settings at startup. Invalid values cause immediate failure with clear error messages. See `src/fastapi_420/config.py:165-179`:
|
||||||
|
```python
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_limits(self) -> RateLimiterSettings:
|
||||||
|
RateLimitRule.parse(self.DEFAULT_LIMIT) # Raises if invalid
|
||||||
|
for limit in self.DEFAULT_LIMITS:
|
||||||
|
RateLimitRule.parse(limit)
|
||||||
|
return self
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Bottlenecks
|
||||||
|
|
||||||
|
Where this system gets slow under load:
|
||||||
|
|
||||||
|
1. **Redis network latency** - Every rate limit check requires at least one Redis call. At 5ms latency, max throughput is 200 requests/sec per connection. Solution: Connection pooling (default 100 connections = 20k requests/sec).
|
||||||
|
|
||||||
|
2. **Fingerprint computation** - Extracting and hashing headers takes ~100 microseconds. Under 10k requests/sec, this is 1 second of CPU time. Solution: Cache fingerprints per request in middleware context (not implemented in base project, shown in challenges).
|
||||||
|
|
||||||
|
3. **Lua script compilation** - First execution of a Lua script requires compilation. Subsequent calls use EVALSHA with cached script hash. See `src/fastapi_420/storage/redis_backend.py:106-125` for script loading.
|
||||||
|
|
||||||
|
### Optimizations
|
||||||
|
|
||||||
|
What we did to make it faster:
|
||||||
|
|
||||||
|
- **Pre-loaded Lua scripts**: Scripts load once at startup (`src/fastapi_420/storage/redis_backend.py:106-125`), not on every request. EVALSHA is 10x faster than EVAL.
|
||||||
|
- **Atomic operations**: Single Redis call per rate limit check instead of get-increment-set sequence. Eliminates race conditions and reduces network round trips.
|
||||||
|
- **Connection pooling**: Redis connection pool reuses connections (`src/fastapi_420/storage/redis_backend.py:78-90`). Creating new connections costs ~10ms each.
|
||||||
|
|
||||||
|
Benchmark results (from internal testing):
|
||||||
|
- Memory storage: 50,000 checks/sec on single core
|
||||||
|
- Redis storage (local): 15,000 checks/sec with default pool
|
||||||
|
- Redis storage (remote): 2,000 checks/sec with 50ms latency
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
|
||||||
|
**Vertical scaling:**
|
||||||
|
Add more CPU and memory to API servers. Rate limiter is CPU-bound for memory storage, network-bound for Redis. Vertical scaling helps memory storage but not Redis (limited by single Redis instance throughput).
|
||||||
|
|
||||||
|
**Horizontal scaling:**
|
||||||
|
Add more API servers behind load balancer. Memory storage DOES NOT scale horizontally (each server has independent state). Redis storage scales perfectly (shared state).
|
||||||
|
|
||||||
|
For >100k requests/sec:
|
||||||
|
- Use Redis Cluster (sharding across multiple Redis instances)
|
||||||
|
- Consider Memcached or custom storage backend
|
||||||
|
- Cache fingerprints to reduce computation
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### Decision 1: Sliding Window as Default Algorithm
|
||||||
|
|
||||||
|
**What we chose:**
|
||||||
|
Sliding window counter with weighted interpolation between fixed windows.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- **True sliding window** (store all timestamps): Rejected because O(n) memory per client where n = limit. A 1000/hour limit requires storing 1000 timestamps. Current approach uses O(1) memory (two counters).
|
||||||
|
- **Fixed window**: Rejected because of boundary burst problem. Attackers can make 2x limit by timing requests at window edges.
|
||||||
|
- **Token bucket**: Considered but sliding window is easier to explain. "100 per minute" is clearer than "100 tokens with 1.67/second refill rate."
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Gained: 99.997% accuracy with constant memory, no boundary bursts
|
||||||
|
- Lost: Not 100% accurate (0.003% error), slightly more complex than fixed window
|
||||||
|
|
||||||
|
Implementation at `src/fastapi_420/algorithms/sliding_window.py:18-30` uses the formula from the Redis GCRA algorithm paper.
|
||||||
|
|
||||||
|
### Decision 2: HTTP 420 Instead of 429
|
||||||
|
|
||||||
|
**What we chose:**
|
||||||
|
Return HTTP 420 "Enhance Your Calm" for rate limit violations.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- **HTTP 429**: Standard code, rejected because 420 has better developer experience (memorable, distinctive in logs)
|
||||||
|
- **HTTP 503**: Service Unavailable, rejected because it implies the server is broken, not that the client is too fast
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Gained: Distinctive, friendly message, easy to grep logs for "420"
|
||||||
|
- Lost: Not IANA-registered (some strict HTTP clients might not recognize it)
|
||||||
|
|
||||||
|
The implementation at `src/fastapi_420/exceptions.py:40-68` still includes standard headers (Retry-After, RateLimit-*) for compatibility.
|
||||||
|
|
||||||
|
### Decision 3: Three-Layer Defense
|
||||||
|
|
||||||
|
**What we chose:**
|
||||||
|
Per-user, per-endpoint, and global circuit breaker layers that all must pass.
|
||||||
|
|
||||||
|
**Alternatives considered:**
|
||||||
|
- **Single layer (per-user only)**: Rejected because distributed attacks bypass it
|
||||||
|
- **Two layers (per-user + global)**: Considered but doesn't protect individual endpoints from being overwhelmed while overall traffic is fine
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Gained: Comprehensive protection against different attack types
|
||||||
|
- Lost: Higher latency (3 checks instead of 1), more complex configuration
|
||||||
|
|
||||||
|
See implementation at `src/fastapi_420/defense/layers.py:47-84`. Each layer returns immediately on denial for fast failure.
|
||||||
|
|
||||||
|
## Deployment Architecture
|
||||||
|
|
||||||
|
In production, this typically runs as:
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ Load Balancer │
|
||||||
|
│ (AWS ALB / Nginx / Cloudflare) │
|
||||||
|
└───────────┬────────────────────┬─────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ API Server 1 │ │ API Server 2 │ (N servers)
|
||||||
|
│ FastAPI + │ │ FastAPI + │
|
||||||
|
│ Rate Limiter │ │ Rate Limiter │
|
||||||
|
└────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Redis Cluster │
|
||||||
|
│ (Shared state) │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **Load Balancer**: Distributes traffic, SSL termination, sets X-Forwarded-For header
|
||||||
|
- **API Servers**: Run FastAPI with rate limiting middleware, 4-8 instances typical
|
||||||
|
- **Redis Cluster**: 3-node cluster for high availability, handles 100k+ ops/sec
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
Each API server: 2 vCPU, 4GB RAM, runs in Docker container
|
||||||
|
Redis: 4GB RAM, persistence enabled, replica for failover
|
||||||
|
|
||||||
|
## Error Handling Strategy
|
||||||
|
|
||||||
|
### Error Types
|
||||||
|
|
||||||
|
1. **Storage connection failures** - Redis is down or network partitioned. Handled at `src/fastapi_420/limiter.py:265-284` with fallback to memory storage if `FALLBACK_TO_MEMORY=true`.
|
||||||
|
|
||||||
|
2. **Invalid configuration** - Malformed rate limit strings like "abc/minute". Caught at startup by Pydantic validators (`src/fastapi_420/config.py:165-179`), application doesn't start.
|
||||||
|
|
||||||
|
3. **Race conditions** - Multiple requests trying to increment the same counter simultaneously. Prevented by Lua scripts which execute atomically in Redis.
|
||||||
|
|
||||||
|
### Recovery Mechanisms
|
||||||
|
|
||||||
|
**Redis connection loss:**
|
||||||
|
- Detection: Health check fails at `src/fastapi_420/storage/redis_backend.py:465-472`
|
||||||
|
- Response: Switch to fallback MemoryStorage if configured
|
||||||
|
- Recovery: Background task retries connection every 30 seconds
|
||||||
|
|
||||||
|
**Circuit breaker tripped:**
|
||||||
|
- Detection: Global request count exceeds threshold
|
||||||
|
- Response: Reject most traffic, allow authenticated users (adaptive mode)
|
||||||
|
- Recovery: After `CIRCUIT_RECOVERY_TIME` seconds, enter half-open state, gradually allow traffic
|
||||||
|
|
||||||
|
## Extensibility
|
||||||
|
|
||||||
|
### Where to Add Features
|
||||||
|
|
||||||
|
Want to add a new algorithm (e.g., leaky bucket)?
|
||||||
|
|
||||||
|
1. Create `src/fastapi_420/algorithms/leaky_bucket.py` implementing `BaseAlgorithm` protocol
|
||||||
|
2. Add to algorithm factory map at `src/fastapi_420/algorithms/__init__.py:17`
|
||||||
|
3. Update `Algorithm` enum in `src/fastapi_420/types.py:28-33`
|
||||||
|
4. Write tests in `tests/test_algorithms.py`
|
||||||
|
|
||||||
|
Want to add geolocation-based blocking?
|
||||||
|
|
||||||
|
1. Extend `FingerprintData` in `src/fastapi_420/types.py:158` with `country_code` field
|
||||||
|
2. Add geo lookup in `CompositeFingerprinter.extract()` at `src/fastapi_420/fingerprinting/composite.py:96`
|
||||||
|
3. Add blocking logic in `LayeredDefense._should_bypass_circuit()` at `src/fastapi_420/defense/layers.py:232`
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
Current architectural limitations:
|
||||||
|
|
||||||
|
1. **No distributed circuit breaker** - Circuit breaker state is per-process. In a 10-server deployment, each server has its own circuit. Total threshold is 10x configured value. Fix requires: Shared circuit state in Redis.
|
||||||
|
|
||||||
|
2. **No adaptive rate limits** - Limits are static, don't adjust based on system load. Under heavy load, might want to reduce limits automatically. Fix requires: Monitor system metrics (CPU, memory) and dynamically calculate limits.
|
||||||
|
|
||||||
|
3. **No request cost weighting** - All requests count as 1. A request that does heavy computation should count more. Fix requires: Add `cost` parameter to `limiter.check()`, multiply count by cost.
|
||||||
|
|
||||||
|
These are not bugs, they're conscious tradeoffs. Fixing them would require significant additional complexity.
|
||||||
|
|
||||||
|
## Comparison to Similar Systems
|
||||||
|
|
||||||
|
### vs. SlowAPI (Flask-Limiter port)
|
||||||
|
|
||||||
|
How we're different:
|
||||||
|
- Native async support (SlowAPI uses sync code with thread pooling)
|
||||||
|
- Three-layer defense vs single-layer
|
||||||
|
- Multiple algorithms built-in vs fixed window only
|
||||||
|
|
||||||
|
Why we made different choices:
|
||||||
|
This project targets high-throughput async APIs. SlowAPI targets traditional Flask apps. Different use cases lead to different architectures.
|
||||||
|
|
||||||
|
### vs. Upstash Rate Limit (serverless-first)
|
||||||
|
|
||||||
|
How we're different:
|
||||||
|
- Self-hosted Redis vs Upstash cloud service
|
||||||
|
- Middleware-based vs edge function integration
|
||||||
|
- Per-server vs globally distributed state
|
||||||
|
|
||||||
|
Why we made different choices:
|
||||||
|
Upstash optimizes for serverless edge deployments (Vercel, Cloudflare Workers). This project targets traditional server deployments with more control over infrastructure.
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
Quick map of where to find things:
|
||||||
|
|
||||||
|
- `src/fastapi_420/limiter.py` - Main orchestrator, start here to understand flow
|
||||||
|
- `src/fastapi_420/middleware.py` - ASGI integration, how it hooks into FastAPI
|
||||||
|
- `src/fastapi_420/algorithms/sliding_window.py` - Recommended default algorithm
|
||||||
|
- `src/fastapi_420/storage/redis_backend.py` - Production storage backend
|
||||||
|
- `src/fastapi_420/storage/lua/` - Atomic Lua scripts for Redis
|
||||||
|
- `src/fastapi_420/defense/layers.py` - Three-layer protection system
|
||||||
|
- `src/fastapi_420/fingerprinting/composite.py` - Client identification
|
||||||
|
- `src/fastapi_420/config.py` - All configuration with validation
|
||||||
|
- `examples/app.py` - Complete working example
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Now that you understand the architecture:
|
||||||
|
1. Read [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) for detailed code walkthrough showing how each component is built
|
||||||
|
2. Try modifying `examples/app.py` to add custom rate limits on new endpoints
|
||||||
Loading…
Reference in New Issue