Merge pull request #13 from CarterPerez-dev/project/fastapi-rate-limiter

Project/fastapi rate limiter
This commit is contained in:
Carter Perez 2025-12-29 21:07:01 -05:00 committed by GitHub
commit c12746236e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 11137 additions and 0 deletions

View File

@ -0,0 +1,46 @@
[style]
based_on_style = pep8
column_limit = 75
indent_width = 4
continuation_indent_width = 4
indent_closing_brackets = false
dedent_closing_brackets = true
indent_blank_lines = false
spaces_before_comment = 2
spaces_around_power_operator = false
spaces_around_default_or_named_assign = true
space_between_ending_comma_and_closing_bracket = false
space_inside_brackets = false
spaces_around_subscript_colon = true
blank_line_before_nested_class_or_def = false
blank_line_before_class_docstring = false
blank_lines_around_top_level_definition = 2
blank_lines_between_top_level_imports_and_variables = 2
blank_line_before_module_docstring = false
split_before_logical_operator = true
split_before_first_argument = true
split_before_named_assigns = true
split_complex_comprehension = true
split_before_expression_after_opening_paren = false
split_before_closing_bracket = true
split_all_comma_separated_values = true
split_all_top_level_comma_separated_values = false
coalesce_brackets = false
each_dict_entry_on_separate_line = true
allow_multiline_lambdas = false
allow_multiline_dictionary_keys = false
split_penalty_import_names = 0
join_multiple_lines = false
align_closing_bracket_with_visual_indent = true
arithmetic_precedence_indication = false
split_penalty_for_added_line_split = 275
use_tabs = false
split_before_dot = false
split_arguments_when_comma_terminated = true
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
i18n_comment = ['# Translators:', '# i18n:']
split_penalty_comprehension = 80
split_penalty_after_opening_bracket = 280
split_penalty_before_if_expr = 0
split_penalty_bitwise_operator = 290
split_penalty_logical_operator = 0

View File

@ -0,0 +1,283 @@
# 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

View File

@ -0,0 +1,223 @@
"""
AngelaMos | 2025
app.py
"""
from __future__ import annotations
import uvicorn
from typing import Annotated
from contextlib import asynccontextmanager
from fastapi import (
Depends,
FastAPI,
Request,
)
from fastapi_420 import (
RateLimiter,
RateLimiterSettings,
RateLimitMiddleware,
ScopedRateLimiter,
FingerprintSettings,
StorageSettings,
set_global_limiter,
RateLimitDep,
)
from fastapi_420.types import Algorithm, FingerprintLevel
storage_settings = StorageSettings(
REDIS_URL = "redis://localhost:6767/0",
MEMORY_MAX_KEYS = 50_000,
)
fingerprint_settings = FingerprintSettings(
LEVEL = FingerprintLevel.NORMAL,
TRUST_X_FORWARDED_FOR = True,
)
settings = RateLimiterSettings(
ALGORITHM = Algorithm.SLIDING_WINDOW,
KEY_PREFIX = "myapp",
INCLUDE_HEADERS = True,
FAIL_OPEN = True,
LOG_VIOLATIONS = True,
storage = storage_settings,
fingerprint = fingerprint_settings,
)
limiter = RateLimiter(settings = settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
await limiter.init()
set_global_limiter(limiter)
yield
await limiter.close()
app = FastAPI(
title = "Rate Limited API",
description = "Example API demonstrating fastapi_420 rate limiting",
lifespan = lifespan,
)
auth_limiter = ScopedRateLimiter(
prefix = "/auth",
default_rules = ["5/minute",
"20/hour"],
endpoint_rules = {
"POST:/auth/login": ["3/minute",
"10/hour"],
"POST:/auth/register": ["2/minute",
"5/hour"],
"POST:/auth/forgot-password": ["2/minute",
"5/hour"],
},
)
public_limiter = ScopedRateLimiter(
prefix = "/public",
default_rules = ["100/minute",
"1000/hour"],
)
user_limiter = ScopedRateLimiter(
prefix = "/api",
default_rules = ["60/minute",
"500/hour"],
endpoint_rules = {
"POST:/api/upload": ["10/minute"],
"GET:/api/export": ["5/minute"],
},
)
@app.post("/auth/login", dependencies = [Depends(auth_limiter)])
async def login(username: str, password: str):
"""
Strict rate limit: 3/minute, 10/hour
Prevents brute force attacks
"""
return {"token": "fake_jwt_token", "user": username}
@app.post("/auth/register", dependencies = [Depends(auth_limiter)])
async def register(username: str, email: str, password: str):
"""
Very strict: 2/minute, 5/hour
Prevents mass account creation
"""
return {"user_id": 123, "username": username}
@app.post("/auth/forgot-password", dependencies = [Depends(auth_limiter)])
async def forgot_password(email: str):
"""
Very strict: 2/minute, 5/hour
Prevents email bombing
"""
return {"message": "If that email exists, we sent a reset link"}
@app.get("/public/products")
@limiter.limit("100/minute")
async def list_products(request: Request):
"""
Relaxed limit for public browsing
Uses decorator style
"""
return {"products": [{"id": 1, "name": "Widget"}]}
@app.get("/public/search")
@limiter.limit("50/minute")
async def search(request: Request, q: str):
"""
Slightly stricter for search (more expensive)
"""
return {"results": [], "query": q}
@app.get("/api/me", dependencies = [Depends(user_limiter)])
async def get_current_user():
"""
Standard user endpoint: 60/minute
"""
return {"user_id": 123, "username": "johndoe"}
@app.get(
"/api/dashboard",
dependencies = [Depends(user_limiter)],
)
async def dashboard():
"""
Standard user endpoint: 60/minute
"""
return {"stats": {"visits": 1000, "conversions": 50}}
@app.post("/api/upload", dependencies = [Depends(user_limiter)])
async def upload_file():
"""
Stricter for uploads: 10/minute
"""
return {"file_id": "abc123", "status": "uploaded"}
@app.get("/api/export", dependencies = [Depends(user_limiter)])
async def export_data():
"""
Very strict for exports: 5/minute
Expensive operation
"""
return {"download_url": "/files/export_123.csv"}
@app.get(
"/api/settings",
dependencies = [Depends(RateLimitDep("30/minute"))],
)
async def get_settings():
"""
Using RateLimitDep directly for one off limits
"""
return {"theme": "dark", "notifications": True}
@app.get("/admin/users")
async def admin_list_users():
"""
No rate limit for admin endpoints
In production you would add auth middleware
"""
return {"users": [{"id": 1, "username": "admin"}]}
@app.get("/admin/stats")
async def admin_stats():
"""
No rate limit
"""
return {"total_users": 10000, "active_today": 500}
@app.get("/health")
async def health():
"""
Health check, excluded from rate limiting by default
"""
storage_healthy = await limiter._storage.health_check(
) if limiter._storage else False
return {
"status": "healthy",
"limiter_initialized": limiter.is_initialized,
"storage_healthy": storage_healthy,
}
if __name__ == "__main__":
uvicorn.run(app, host = "0.0.0.0", port = 8000)

View File

@ -0,0 +1,16 @@
services:
redis:
image: redis:7-alpine
ports:
- "6767:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
redis_data:

View File

@ -0,0 +1,141 @@
# How the Rate Limiting Algorithms Actually Work
This package ships three algorithms. Most people just pick one without understanding what they actually do differently. Here is what is happening under the hood.
## The Short Version
| Algorithm | Good at | Bad at |
|-----------|---------|--------|
| Sliding Window | Accuracy, memory efficiency | Nothing really |
| Token Bucket | Handling bursts gracefully | Can feel unpredictable |
| Fixed Window | Simplicity, speed | Boundary bursts (see below) |
Sliding window is the default because Cloudflare tested it across 400 million requests and measured 99.997% accuracy. That is not marketing, that is just what the math works out to.
## Fixed Window (and why it has a problem)
Fixed window is the simplest approach. Divide time into chunks (say, 1 minute windows), count requests in each chunk, block when the count hits the limit.
```
Window 1 (0:00-0:59) Window 2 (1:00-1:59)
[--------------------] [--------------------]
87 requests 12 requests
```
The problem nobody thinks about: what happens at the boundary?
Say you have a 100 requests/minute limit. A client sends 100 requests at 0:59 (allowed, counter is 100). Then at 1:00, the window resets and they send another 100 requests (allowed, new window). That is 200 requests in 2 seconds. The limit is supposed to be 100/minute but they got 200.
This is called the boundary burst problem. It is not theoretical. Attackers know about it.
```
0:00 0:59 1:00 1:59
[---------------||--][--||---------------]
100 100
^
200 requests in 2 seconds
```
Fixed window is still useful when you need maximum speed and can tolerate this edge case. But for most APIs, you want something better.
## Sliding Window Counter (the default)
This fixes the boundary problem by looking at two windows and doing weighted interpolation.
The idea: instead of hard window boundaries, calculate a weighted average based on how far into the current window you are.
```python
elapsed_ratio = (current_time % window_size) / window_size
weighted_count = (previous_window_count * (1 - elapsed_ratio)) + current_window_count
```
Say you are 40% into the current minute. You take 60% of the previous window count plus 100% of the current window count. This creates a "sliding" effect where the window smoothly moves forward rather than jumping.
```
Previous window Current window
[-------50-------] [---20---.........]
^
40% elapsed
weighted = (50 * 0.6) + 20 = 30 + 20 = 50
```
Memory cost is just two integers per client (previous count, current count). The 99.997% accuracy comes from the fact that you are approximating a true sliding window with minimal storage.
The tradeoff is about 6% average error compared to a perfect sliding window log. In practice nobody notices.
## Token Bucket (for bursty traffic)
Token bucket thinks about rate limiting differently. Instead of counting requests, you have a bucket that fills with tokens over time. Each request takes a token. No tokens, no request.
```
Bucket capacity: 10 tokens
Refill rate: 1 token/second
[OOOOOOOOOO] <- full bucket (10 tokens)
5 requests come in at once:
[OOOOO-----] <- 5 tokens left
Wait 3 seconds:
[OOOOOOOO--] <- refilled to 8 tokens
```
The key difference from sliding window: burst tolerance.
With sliding window at 60 requests/minute, a client can only ever make 60 requests in any 60 second period. Steady, predictable.
With token bucket at 60 tokens/minute capacity and 1 token/second refill, a client can burn all 60 tokens instantly if they saved up, then wait for refills. Bursty, but still respects the average rate.
The math:
```python
tokens = min(capacity, tokens + (elapsed_time * refill_rate))
if tokens >= 1:
tokens -= 1
return allowed
return denied
```
When to use it: APIs where legitimate clients have bursty patterns. Mobile apps that sync on open. Batch processing endpoints. Anything where "60 per minute average" makes more sense than "never more than 60 in any minute."
## How We Store This Stuff
All three algorithms need to track state per client. The storage layer handles this.
For sliding window, we store:
```
key: "rl:v1:user:GET:/api/data:abc123:60"
value: {count: 47, window_start: 1703894400}
```
For token bucket:
```
key: "rl:v1:user:GET:/api/data:abc123:bucket"
value: {tokens: 8.5, last_update: 1703894567.123}
```
Memory storage uses Python dicts with LRU eviction when you hit max keys. Redis storage uses Lua scripts to make the read-modify-write atomic (more on why in the architecture doc).
## Picking an Algorithm
Most of the time: sliding window. It is accurate, memory efficient, and has no weird edge cases.
Use token bucket when:
- Your clients legitimately need burst capacity
- "Average rate" matters more than "instantaneous rate"
- You are rate limiting something like file uploads or batch operations
Use fixed window when:
- You need maximum performance and minimal complexity
- The boundary burst is acceptable for your use case
- You are doing something like daily API quotas where minute-level precision does not matter
## The Math Behind Sliding Window Accuracy
If you are curious why the 6% error claim holds up:
The worst case is when all requests from the previous window happened at the very end, and all requests from the current window happened at the very beginning. The weighted formula slightly miscounts in this scenario.
But in practice, requests are distributed somewhat evenly (especially under attack, which is when accuracy matters most). Real world testing shows the error averages around 0.003% under normal load. The 6% figure is the theoretical worst case with adversarial timing.
For rate limiting, this is more than good enough. You are not doing financial accounting here. Being off by one or two requests out of a hundred is fine.

View File

@ -0,0 +1,247 @@
# Architecture and Design Decisions
This doc explains why the package is built the way it is. Not how to use it, but why certain decisions were made.
## The Three Layer Defense Model
The limiter uses three layers of protection that work together:
1. **Per user, per endpoint** - Each user gets their own limit for each endpoint
2. **Per endpoint global** - Each endpoint has an overall limit across all users
3. **Circuit breaker** - Global kill switch when things go really wrong
Why three layers? Because different attacks hit different surfaces.
A credential stuffing attack hammers one endpoint (`/login`) from many IPs. Per user limits do not help because each IP only tries a few times. You need a global endpoint limit.
A single abusive user with a valid account might scrape your entire API. Global limits do not help because they are lost in normal traffic. You need per user limits.
A massive DDoS might overwhelm everything. Individual limits cannot keep up with the volume. You need a circuit breaker that just shuts things down temporarily.
```
Request
┌─────────────────────┐
│ Per User/Endpoint │ ─── Stops individual abuse
└─────────────────────┘
┌─────────────────────┐
│ Per Endpoint Global │ ─── Stops coordinated attacks
└─────────────────────┘
┌─────────────────────┐
│ Circuit Breaker │ ─── Emergency stop
└─────────────────────┘
Allowed
```
## Why Fail Open is the Default
When Redis goes down, you have two choices:
1. **Fail closed** - Block all requests until Redis comes back
2. **Fail open** - Allow all requests until Redis comes back
This package defaults to fail open. Here is why.
Rate limiting is a protective measure, not a core business function. If your rate limiter fails, the worst case is some extra load for a few minutes. If you fail closed, your entire API goes down.
Think about it from an attacker's perspective. If they know you fail closed, they just need to DoS your Redis instance and your whole API dies. You turned a rate limiting dependency into a single point of failure.
The fail open approach includes an in-memory fallback. When Redis is unreachable, the limiter switches to local memory storage. It is not perfect (each server instance counts separately) but it is better than nothing and way better than blocking everything.
```python
# What happens internally
try:
return await redis_storage.check(key, limit)
except RedisConnectionError:
logger.warning("Redis down, using memory fallback")
return await memory_storage.check(key, limit)
```
You can change this behavior with `FAIL_OPEN=False` if your use case genuinely requires fail closed semantics. But think hard about whether you actually need that.
## Why Redis Needs Lua Scripts
Rate limiting seems simple: read counter, increment, check limit. But there is a race condition hiding in plain sight.
```python
# This looks fine but it is broken
count = await redis.get(key)
if count < limit:
await redis.incr(key)
return allowed
return denied
```
The problem: between the GET and INCR, another request can sneak in.
```
Request A: GET key -> 99
Request B: GET key -> 99
Request A: 99 < 100, so INCR -> 100
Request B: 99 < 100, so INCR -> 101 # Limit bypassed!
```
This is not theoretical. Under load, this happens constantly. Attackers can deliberately time requests to exploit it.
The fix is atomic operations. Redis Lua scripts run as a single atomic unit. Nothing can interleave.
```lua
local count = tonumber(redis.call('GET', key)) or 0
if count < limit then
redis.call('INCR', key)
return 1 -- allowed
end
return 0 -- denied
```
We also use EVALSHA instead of EVAL. EVAL sends the entire script text every time. EVALSHA sends a 40 byte hash and Redis looks up the cached script. Saves bandwidth on every single request.
## Composite Fingerprinting
IP based rate limiting has an obvious problem: attackers can rotate IPs.
Botnets have millions of IPs. Cloud providers give you a new IP every few seconds. IPv6 lets you control billions of addresses from a single allocation.
Composite fingerprinting combines multiple signals to identify clients:
```
IP Address
+
User-Agent
+
Accept Headers
+
Auth Token (hashed)
=
Fingerprint
```
The idea is that while IPs are easy to rotate, the combination of browser characteristics is harder to fake consistently. A real browser has a specific User-Agent, accepts specific encodings and languages, and generally looks consistent across requests.
An attacker scripting requests often:
- Uses a generic or missing User-Agent
- Has inconsistent or minimal Accept headers
- Changes these values between requests (which itself is a signal)
The fingerprint levels:
| Level | What it uses | When to use |
|-------|--------------|-------------|
| RELAXED | Just IP | High volume public APIs, CDN cached content |
| NORMAL | IP + User-Agent | Most APIs (default) |
| STRICT | IP + User-Agent + Accept + Auth | Sensitive endpoints, auth flows |
Stricter is not always better. STRICT mode might accidentally rate limit legitimate users behind corporate proxies who share an IP but have different auth tokens.
## IPv6 Requires Special Handling
Most rate limiters treat each IP as unique. This is fine for IPv4 where addresses are scarce.
IPv6 is different. ISPs typically allocate a /64 prefix to each customer, which means one user controls 18 quintillion addresses. They can rotate through a new IP for every single request and never repeat.
The fix is to normalize IPv6 addresses to their /64 prefix before using them as rate limit keys.
```python
from ipaddress import ip_address, ip_network
def normalize_ip(ip: str) -> str:
addr = ip_address(ip)
if addr.version == 6:
# Treat entire /64 as one "user"
network = ip_network(f"{ip}/64", strict=False)
return str(network.network_address)
return ip
```
Now `2001:db8::1` and `2001:db8::ffff:ffff:ffff:ffff` become the same key: `2001:db8::`.
The /64 choice is not arbitrary. It matches how ISPs actually allocate addresses to end users. Smaller prefixes risk grouping unrelated users. Larger prefixes miss the attack surface.
## Key Naming Strategy
Rate limit keys need to be:
- Unique per client/endpoint/window combination
- Predictable (so you can debug)
- Namespaced (so they do not collide with other Redis usage)
The format:
```
{prefix}:{version}:{layer}:{endpoint}:{identifier}:{window}
```
Example:
```
rl:v1:user:GET:/api/users:a1b2c3d4:60
```
Breaking it down:
- `rl` - Key prefix, configurable, avoids collision with other Redis keys
- `v1` - Version, lets you migrate key formats without clearing everything
- `user` - Layer (user, endpoint, or global)
- `GET:/api/users` - Endpoint including method (GET and POST are separate limits)
- `a1b2c3d4` - First 16 chars of fingerprint hash
- `60` - Window size in seconds
The version field is important for migrations. If you change how keys are structured, bump the version and old keys will naturally expire while new keys use the new format.
## Response Headers
The package adds standard rate limit headers to responses:
```
RateLimit-Limit: 100
RateLimit-Remaining: 67
RateLimit-Reset: 45
```
These follow the IETF draft standard (draft-ietf-httpapi-ratelimit-headers). Using standard headers means clients do not need custom code to handle your API.
When a request is blocked:
```
HTTP/1.1 420 Enhance Your Calm
Retry-After: 45
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 45
```
The Retry-After header tells clients exactly how long to wait. Good clients respect this. Bad clients ignore it and get blocked again.
## Why HTTP 420
The spec says to use 429 Too Many Requests. We use 420 instead.
Twitter invented 420 "Enhance Your Calm" for their rate limiting before the 429 standard existed. It is technically non-standard now but it is more memorable and honestly more fun.
The code works the same either way. We just return a different number and a message telling you to calm down.
If you genuinely need 429 for compatibility, you can catch `EnhanceYourCalm` and return your own response. But where is the fun in that?
## Storage Abstraction
The storage layer is abstracted so you can swap backends:
```python
class Storage(Protocol):
async def increment(self, key: str, window: int, limit: int) -> Result
async def get_window_state(self, key: str, window: int) -> State
async def consume_token(self, key: str, capacity: int, rate: float) -> Result
async def health_check(self) -> bool
async def close(self) -> None
```
Currently implemented:
- **MemoryStorage** - Dict based, good for dev and single instance deployments
- **RedisStorage** - Lua script based, required for distributed deployments
The abstraction means you can add new backends (Memcached, DynamoDB, whatever) without touching the limiter logic.
MemoryStorage includes LRU eviction when you hit max keys. This prevents unbounded memory growth from attackers creating millions of unique fingerprints. The default is 100k keys which is about 2.4MB for sliding window counters.

View File

@ -0,0 +1,325 @@
# Security Considerations and Common Mistakes
Rate limiting seems simple until you realize how many ways it can fail. This doc covers real vulnerabilities, attack vectors, and the mistakes that show up in CVEs over and over again.
## Real CVEs: What Goes Wrong
### CVE-2023-2531 (AzuraCast) - X-Forwarded-For Spoofing
CVSS 7.5. The `getIp()` method trusted the X-Forwarded-For header without validation.
```python
# What they did (broken)
def get_ip(request):
if 'X-Forwarded-For' in request.headers:
return request.headers['X-Forwarded-For'].split(',')[0]
return request.remote_addr
```
An attacker just sends:
```
X-Forwarded-For: 1.2.3.4
```
Now every request looks like it comes from a different IP. Rate limiting completely bypassed.
The fix is to only trust X-Forwarded-For from known proxy IPs:
```python
TRUSTED_PROXIES = {'10.0.0.1', '10.0.0.2'}
def get_ip(request):
if request.remote_addr in TRUSTED_PROXIES:
forwarded = request.headers.get('X-Forwarded-For', '')
if forwarded:
return forwarded.split(',')[0].strip()
return request.remote_addr
```
This package has `TRUST_X_FORWARDED_FOR=False` by default. You have to explicitly enable it and should also configure `TRUSTED_PROXIES`.
### CVE-2023-46745 (LibreNMS) - Inconsistent Limit Application
Rate limiting was applied to POST `/login` but not GET `/login`. The login form could be submitted via GET with query parameters, bypassing limits entirely.
```
POST /login <- rate limited
GET /login?username=x&password=y <- not rate limited
```
The lesson: rate limit the action, not just a specific method. This package includes the HTTP method in the endpoint key by default (`GET:/api/data` vs `POST:/api/data`), but you need to make sure all paths to sensitive operations are covered.
### GHSA-984p-xq9m-4rjw (express-brute) - Race Conditions
Non-atomic counter updates allowed concurrent requests to bypass limits. Classic TOCTOU (time of check to time of use) bug.
```javascript
// Broken: gap between read and write
const count = await store.get(key);
if (count < limit) {
await store.set(key, count + 1); // Race condition here
next();
}
```
Between the get and set, dozens of requests can sneak through. Under load, this completely defeats rate limiting.
This is why we use Redis Lua scripts. The entire check-and-increment runs atomically.
## Attack Vectors to Understand
### Header Spoofing
Any header can be spoofed. X-Forwarded-For, X-Real-IP, CF-Connecting-IP. Attackers know which headers your reverse proxy sets.
The only safe approach:
1. Know exactly which proxies sit in front of your app
2. Only trust forwarded headers from those specific IPs
3. Take the rightmost IP added by your trusted proxy, not the leftmost (which the client controls)
```
X-Forwarded-For: attacker-spoofed, real-proxy-added
^ ^
client controls proxy controls
```
### GraphQL Batching
This one catches people off guard. GraphQL lets you batch multiple operations in one HTTP request:
```json
[
{"query": "mutation { login(user: \"x\", pass: \"a\") }"},
{"query": "mutation { login(user: \"x\", pass: \"b\") }"},
{"query": "mutation { login(user: \"x\", pass: \"c\") }"}
]
```
That is one HTTP request but three login attempts. If you rate limit by request, you are letting 100 password guesses through per "request."
GraphQL endpoints need operation-level limiting:
- Count mutations, not requests
- Limit query depth and complexity
- Cap batch sizes (5-10 operations max)
### Slowloris and Connection Exhaustion
Rate limiting usually focuses on completed requests. But what about requests that never complete?
Slowloris attacks open many connections and send data very slowly, tying up server resources without triggering request-based limits.
This is outside the scope of application-level rate limiting. You need:
- Connection timeouts at the reverse proxy level
- Per-IP connection limits in nginx/haproxy
- Request body size limits
### IPv6 Address Rotation
Covered in the architecture doc, but worth repeating: a single residential IPv6 user controls 18 quintillion addresses in their /64 prefix.
```
2001:db8:abcd:1234::1
2001:db8:abcd:1234::2
2001:db8:abcd:1234::ffff:ffff:ffff:ffff
```
All controlled by the same person. If you rate limit per IP, they just rotate.
Normalize to /64 prefix before rate limiting:
```python
2001:db8:abcd:1234::1 -> 2001:db8:abcd:1234::
```
## Redis Security
Redis is a common target. Default installations often have no authentication and listen on all interfaces.
### CVE-2025-49844 (RediShell) - CVSS 10.0
Remote code execution through Lua sandbox escape. Affected around 330,000 exposed instances.
Protection:
1. Always require authentication (`requirepass`)
2. Bind to localhost only unless you specifically need network access
3. Use ACLs to restrict what the rate limiter can do
4. Disable dangerous commands
```redis
# Create a restricted user for rate limiting
ACL SETUSER ratelimiter on >strongpassword ~rl:* +EVALSHA +GET +SET +INCR +EXPIRE +TTL
```
This user can only:
- Run EVALSHA (our Lua scripts)
- Access keys starting with `rl:`
- Do basic counter operations
They cannot FLUSHALL, CONFIG, DEBUG, or do anything administrative.
### Key Expiration
Every rate limit key must have a TTL. Without expiration:
- Memory grows forever
- Old keys from users who never return pile up
- Attackers can create millions of keys by rotating identifiers
We set TTL to 2x the window size. A 60 second window gets a 120 second TTL. This ensures the key lives long enough for the sliding window to work, but expires after.
## Common Implementation Mistakes
### 1. Not Testing Under Load
Unit tests pass. Integration tests pass. Production gets slammed and requests slip through.
Race conditions only appear under concurrent load. You need to actually test with hundreds of simultaneous requests:
```python
async def test_concurrent_limit():
tasks = [make_request() for _ in range(200)]
results = await asyncio.gather(*tasks)
allowed = sum(1 for r in results if r.status == 200)
blocked = sum(1 for r in results if r.status == 420)
# With limit of 100, should see ~100 allowed, ~100 blocked
assert 95 <= allowed <= 105
```
### 2. Blocking Logging
```python
# Bad: blocks the event loop
logger.info(f"Rate limited {ip}") # Synchronous I/O
```
Under attack, you might rate limit thousands of requests per second. Synchronous logging blocks the event loop and slows everything down.
Use async logging, batch writes, or background tasks:
```python
# Better: non-blocking
background_tasks.add_task(log_violation, ip, endpoint)
```
### 3. Forgetting TTLs
```python
# Bad: key lives forever
await redis.incr(key)
# Good: key expires
await redis.incr(key)
await redis.expire(key, window_seconds * 2)
```
Actually even better, do it atomically in Lua so there is no gap where the key exists without a TTL.
### 4. Integer Overflow
At extreme scale, counters can overflow. Python handles big integers fine, but Redis stores numbers as 64-bit signed integers. Max value is about 9.2 quintillion.
You probably will not hit this, but if you are doing high volume with long windows, consider it.
### 5. Clock Drift
Distributed systems with multiple app servers can have clock skew. Server A thinks it is 10:00:00, Server B thinks it is 10:00:02.
For sliding window, use Redis server time instead of local time:
```python
server_time = await redis.time() # Returns (seconds, microseconds)
```
Now all servers agree on what time it is.
### 6. Fail Closed Without Monitoring
If you choose to fail closed (block all requests when Redis is down), you absolutely need monitoring and alerting on Redis availability.
Otherwise Redis goes down at 3am, your API becomes completely unavailable, and you do not find out until customers complain.
### 7. Overly Strict Fingerprinting
STRICT fingerprinting mode includes Accept headers and auth tokens. But this can cause problems:
- Same user on different browsers gets different fingerprints
- API clients with slightly different Accept headers look like different users
- Token rotation creates new fingerprints
Start with NORMAL (IP + User-Agent) and only go stricter if you have a specific problem to solve.
## OWASP API4:2023 - Unrestricted Resource Consumption
Rate limiting directly addresses OWASP API4, which identifies these failure modes:
- Missing or inadequate rate limiting
- Lack of payload size limits
- No execution timeouts
- Unlimited resource allocation per request
A complete defense includes:
- Request rate limits (what this package does)
- Payload size limits (configure in your web server)
- Execution timeouts (configure in your ASGI server)
- Memory limits (configure at the container/process level)
Rate limiting is one layer. You need the others too.
## Testing Your Implementation
### Verify limits actually work
```bash
# Send 110 requests, expect ~100 to succeed
for i in {1..110}; do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/test
done | sort | uniq -c
```
Should see roughly 100 responses of 200 and 10 responses of 420.
### Verify headers are present
```bash
curl -i http://localhost:8000/api/test
```
Look for:
```
RateLimit-Limit: 100
RateLimit-Remaining: 99
RateLimit-Reset: 58
```
### Test concurrent requests
```python
import asyncio
import httpx
async def blast():
async with httpx.AsyncClient() as client:
tasks = [client.get("http://localhost:8000/api/test") for _ in range(200)]
responses = await asyncio.gather(*tasks)
codes = [r.status_code for r in responses]
print(f"200: {codes.count(200)}, 420: {codes.count(420)}")
asyncio.run(blast())
```
### Test failover
```bash
# Stop Redis
docker stop redis
# Requests should still work (fail open)
curl http://localhost:8000/api/test
# Check logs for fallback warning
```
## Reporting Security Issues
If you find a security vulnerability in this package, do not open a public issue. Email the maintainer directly so it can be fixed before disclosure.

View File

@ -0,0 +1,242 @@
[project]
name = "fastapi-420"
version = "0.1.0"
description = "Enhance Your Calm - Advanced Rate Limiting & DDoS Protection for FastAPI"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.12"
keywords = ["fastapi", "rate-limiting", "ddos", "security", "api", "420"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Security",
"Typing :: Typed",
]
dependencies = [
"fastapi[standard]>=0.123.0,<1.0.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.1.0",
"pyjwt>=2.10.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.1",
"fakeredis>=2.26.0",
"time-machine>=2.16.0",
"asgi-lifespan>=2.1.0",
"mypy>=1.19.0",
"types-redis>=4.6.0",
"ruff>=0.14.8",
"pylint>=4.0.4",
"pylint-pydantic>=0.4.1",
"pylint-per-file-ignores>=3.2.0",
"pre-commit>=4.2.0",
]
[project.urls]
Homepage = "https://github.com/CarterPerez-dev/fastapi-420"
Documentation = "https://github.com/CarterPerez-dev/fastapi-420#readme"
Repository = "https://github.com/CarterPerez-dev/fastapi-420"
Issues = "https://github.com/CarterPerez-dev/fastapi-420/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/fastapi_420"]
[tool.ruff]
target-version = "py312"
line-length = 88
src = ["src"]
exclude = ["alembic"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
"ASYNC", # flake8-async
"S", # flake8-bandit (security)
"N", # pep8-naming
]
ignore = [
"E501", # line too long (formatter handles this)
"B008", # function call in default argument (FastAPI Depends)
"S101", # assert usage (needed for tests)
"S104", # 0.0.0.0 binding (intentional for Docker)
"S105", # "bearer" token_type is not a password
"ARG001", # unused function argument (common in FastAPI deps)
"E712", # == False is REQUIRED for SQLAlchemy WHERE clauses
"N999", # PascalCase module names (intentional: Base.py, User.py)
"N818", # exception naming convention (style preference)
"UP046", # Generic[T] syntax (keep for compatibility)
"RUF005", # list concatenation style (preference)
]
[tool.ruff.lint.per-file-ignores]
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
plugins = ["pydantic.mypy"]
exclude = ["alembic", ".venv", "venv"]
[[tool.mypy.overrides]]
module = ["tests.*", "conftest"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["core.logging"]
disable_error_code = ["no-any-return"]
[[tool.mypy.overrides]]
module = [
"uuid6",
"structlog",
"structlog.*",
"pwdlib",
"slowapi",
"slowapi.*",
]
ignore_missing_imports = true
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
[tool.pylint.main]
py-version = "3.11"
jobs = 4
load-plugins = [
"pylint_pydantic",
"pylint_per_file_ignores",
]
persistent = true
ignore = [
"alembic",
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
]
ignore-paths = [
"^alembic/.*",
"^venv/.*",
"^.venv/.*",
"^build/.*",
"^dist/.*",
]
[tool.pylint.messages_control]
disable = [
"C0103", # invalid-name
"C0116", # missing-function-docstring (we use minimal docs)
"C0121", # singleton-comparison (== False required for SQLAlchemy)
"C0301", # line-too-long
"C0302", # too-many-lines
"C0303", # trailing-whitespace
"C0304", # final-newline-missing
"C0305", # trailing-newlines
"C0411", # wrong-import-order
"C0412", # ungrouped-imports (style preference)
"E0401", # import-error (uuid6/structlog/pwdlib not found by pylint)
"E0611", # no-name-in-module (false positive for config re-exports)
"E1102", # not-callable (false positive for SQLAlchemy func.now/count)
"E1136", # unsubscriptable-object (false positive for generics)
"R0801", # similar-lines
"R0901", # too-many-ancestors (SQLAlchemy inheritance)
"R0903", # too-few-public-methods
"R0917", # too-many-positional-arguments (FastAPI patterns)
"W0611", # unused-import (handled by ruff, config.py re-exports)
"W0612", # unused-variable (handled by ruff)
"W0613", # unused-argument (handled by ruff)
"W0621", # redefined-outer-name (FastAPI route/param naming)
"W0622", # redefined-builtin
"W0718", # broad-exception-caught (intentional in health/rate-limit)
]
[tool.pylint.format]
max-line-length = 95
[tool.pylint.design]
max-args = 12
max-attributes = 10
max-branches = 15
max-locals = 20
max-statements = 55
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
addopts = "-ra -q"
filterwarnings = [
"ignore::DeprecationWarning",
]
[tool.coverage.run]
branch = true
source = ["src"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
[tool.ty.src]
include = ["src", "tests"]
exclude = ["alembic/versions/**", ".venv/**"]
respect-ignore-files = true
[tool.ty.environment]
python-version = "3.12"
root = ["./src"]
python = "./.venv"
[tool.ty.rules]
possibly-missing-attribute = "error"
possibly-missing-import = "error"
unused-ignore-comment = "warn"
redundant-cast = "warn"
undefined-reveal = "warn"

View File

@ -0,0 +1,74 @@
"""
AngelaMos | 2025
__init__.py
"""
from fastapi_420.config import (
FingerprintSettings,
RateLimiterSettings,
StorageSettings,
get_settings,
)
from fastapi_420.defense import CircuitBreaker, LayeredDefense
from fastapi_420.dependencies import (
LimiterDep,
RateLimitDep,
ScopedRateLimiter,
create_rate_limit_dep,
get_limiter,
require_rate_limit,
set_global_limiter,
)
from fastapi_420.exceptions import (
EnhanceYourCalm,
HTTP_420_ENHANCE_YOUR_CALM,
RateLimitError,
RateLimitExceeded,
StorageError,
)
from fastapi_420.limiter import RateLimiter
from fastapi_420.middleware import RateLimitMiddleware, SlowDownMiddleware
from fastapi_420.types import (
Algorithm,
DefenseMode,
FingerprintData,
FingerprintLevel,
Layer,
RateLimitResult,
RateLimitRule,
)
__version__ = "0.1.0"
__all__ = [
"HTTP_420_ENHANCE_YOUR_CALM",
"Algorithm",
"CircuitBreaker",
"DefenseMode",
"EnhanceYourCalm",
"FingerprintData",
"FingerprintLevel",
"FingerprintSettings",
"Layer",
"LayeredDefense",
"LimiterDep",
"RateLimitDep",
"RateLimitError",
"RateLimitExceeded",
"RateLimitMiddleware",
"RateLimitResult",
"RateLimitRule",
"RateLimiter",
"RateLimiterSettings",
"ScopedRateLimiter",
"SlowDownMiddleware",
"StorageError",
"StorageSettings",
"__version__",
"create_rate_limit_dep",
"get_limiter",
"get_settings",
"require_rate_limit",
"set_global_limiter",
]

View File

@ -0,0 +1,39 @@
"""
AngelaMos | 2025
__init__.py
"""
from fastapi_420.algorithms.base import BaseAlgorithm
from fastapi_420.algorithms.fixed_window import FixedWindowAlgorithm
from fastapi_420.algorithms.sliding_window import SlidingWindowAlgorithm
from fastapi_420.algorithms.token_bucket import TokenBucketAlgorithm
from fastapi_420.types import Algorithm
def create_algorithm(algorithm_type: Algorithm) -> BaseAlgorithm:
"""
Factory function to create appropriate algorithm instance
"""
algorithm_map: dict[Algorithm,
type[BaseAlgorithm]] = {
Algorithm.SLIDING_WINDOW:
SlidingWindowAlgorithm,
Algorithm.TOKEN_BUCKET: TokenBucketAlgorithm,
Algorithm.FIXED_WINDOW: FixedWindowAlgorithm,
Algorithm.LEAKY_BUCKET: SlidingWindowAlgorithm,
}
algorithm_class = algorithm_map.get(
algorithm_type,
SlidingWindowAlgorithm
)
return algorithm_class()
__all__ = [
"BaseAlgorithm",
"FixedWindowAlgorithm",
"SlidingWindowAlgorithm",
"TokenBucketAlgorithm",
"create_algorithm",
]

View File

@ -0,0 +1,52 @@
"""
AngelaMos | 2025
base.py
"""
# pylint: disable=unnecessary-ellipsis
from __future__ import annotations
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
"""
...
@abstractmethod
async def get_current_usage(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
) -> int:
"""
Get current usage count without incrementing
"""
...

View File

@ -0,0 +1,76 @@
"""
AngelaMos | 2025
fixed_window.py
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from fastapi_420.algorithms.base import BaseAlgorithm
from fastapi_420.storage.redis_backend import RedisStorage
from fastapi_420.types import Algorithm
if TYPE_CHECKING:
from fastapi_420.storage import Storage
from fastapi_420.types import RateLimitResult, RateLimitRule
class FixedWindowAlgorithm(BaseAlgorithm):
"""
Fixed window counter algorithm
Simple implementation but suffers from boundary burst problem:
clients can make 2x the limit by timing requests at window edges.
Use sliding_window for production unless simplicity is required.
"""
@property
def name(self) -> str:
return Algorithm.FIXED_WINDOW.value
async def check(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Check and increment counter using fixed window algorithm
"""
if isinstance(storage, RedisStorage):
return await storage.increment_fixed_window(
key = key,
window_seconds = rule.window_seconds,
limit = rule.requests,
timestamp = timestamp,
)
return await storage.increment(
key = key,
window_seconds = rule.window_seconds,
limit = rule.requests,
timestamp = timestamp,
)
async def get_current_usage(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
) -> int:
"""
Get current window count without incrementing
"""
now = time.time()
current_window = int(now // rule.window_seconds)
window_key = f"{key}:{current_window}"
state = await storage.get_window_state(
key = window_key,
window_seconds = rule.window_seconds,
)
return state.current_count

View File

@ -0,0 +1,66 @@
"""
AngelaMos | 2025
sliding_window.py
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from fastapi_420.algorithms.base import BaseAlgorithm
from fastapi_420.types import Algorithm
if TYPE_CHECKING:
from fastapi_420.storage import Storage
from fastapi_420.types import RateLimitResult, RateLimitRule
class SlidingWindowAlgorithm(BaseAlgorithm):
"""
Sliding window counter algorithm
The recommended default for production rate limiting.
Achieves ~99.997% accuracy with O(1) memory per client.
Uses weighted interpolation between two fixed windows to
approximate a true sliding window.
"""
@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,
)
async def get_current_usage(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
) -> int:
"""
Get current weighted usage count without incrementing
"""
now = time.time()
elapsed_ratio = (now % rule.window_seconds) / rule.window_seconds
state = await storage.get_window_state(
key = key,
window_seconds = rule.window_seconds,
)
return int(state.weighted_count(elapsed_ratio))

View File

@ -0,0 +1,66 @@
"""
AngelaMos | 2025
token_bucket.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi_420.algorithms.base import BaseAlgorithm
from fastapi_420.types import Algorithm
if TYPE_CHECKING:
from fastapi_420.storage import Storage
from fastapi_420.types import RateLimitResult, RateLimitRule
class TokenBucketAlgorithm(BaseAlgorithm):
"""
Token bucket algorithm
Allows controlled bursting up to bucket capacity while
enforcing average rate limits. Tokens refill at a constant
rate and are consumed per request.
Best for APIs that need burst tolerance with eventual rate enforcement.
"""
@property
def name(self) -> str:
return Algorithm.TOKEN_BUCKET.value
async def check(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
timestamp: float | None = None, # noqa: ARG002
) -> RateLimitResult:
"""
Check and consume token using token bucket algorithm
"""
capacity = rule.requests
refill_rate = rule.requests / rule.window_seconds
return await storage.consume_token(
key = key,
capacity = capacity,
refill_rate = refill_rate,
tokens_to_consume = 1,
)
async def get_current_usage(
self,
storage: Storage,
key: str,
rule: RateLimitRule,
) -> int:
"""
Get current token count (inverted as usage)
"""
state = await storage.get_token_bucket_state(key = key)
if state is None:
return 0
return rule.requests - int(state.tokens)

View File

@ -0,0 +1,188 @@
"""
AngelaMos | 2025
config.py
"""
from __future__ import annotations
from functools import lru_cache
from typing import Annotated, Literal
from pydantic import (
Field,
RedisDsn,
model_validator,
)
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
from fastapi_420.types import (
Algorithm,
DefenseMode,
FingerprintLevel,
RateLimitRule,
)
class StorageSettings(BaseSettings):
"""
Storage backend configuration
"""
model_config = SettingsConfigDict(
env_prefix = "RATELIMIT_",
env_file = ".env",
env_file_encoding = "utf-8",
extra = "ignore",
)
REDIS_URL: RedisDsn | None = None
REDIS_MAX_CONNECTIONS: Annotated[int, Field(ge = 1, le = 1000)] = 100
REDIS_SOCKET_TIMEOUT: Annotated[float,
Field(ge = 0.1,
le = 60.0)] = 5.0
REDIS_RETRY_ON_TIMEOUT: bool = True
REDIS_DECODE_RESPONSES: bool = True
MEMORY_MAX_KEYS: Annotated[int,
Field(ge = 100,
le = 10_000_000)] = 100_000
MEMORY_CLEANUP_INTERVAL: Annotated[int, Field(ge = 1, le = 3600)] = 60
FALLBACK_TO_MEMORY: bool = True
class FingerprintSettings(BaseSettings):
"""
Fingerprinting configuration
"""
model_config = SettingsConfigDict(
env_prefix = "RATELIMIT_FP_",
env_file = ".env",
env_file_encoding = "utf-8",
extra = "ignore",
)
LEVEL: FingerprintLevel = FingerprintLevel.NORMAL
USE_IP: bool = True
USE_USER_AGENT: bool = True
USE_ACCEPT_HEADERS: bool = False
USE_HEADER_ORDER: bool = False
USE_AUTH: bool = True
USE_TLS: bool = False
USE_GEO: bool = False
IPV6_PREFIX_LENGTH: Annotated[int, Field(ge = 32, le = 128)] = 64
TRUSTED_PROXIES: list[str] = []
TRUST_X_FORWARDED_FOR: bool = False
class DefenseSettings(BaseSettings):
"""
DDoS defense layer configuration
"""
model_config = SettingsConfigDict(
env_prefix = "RATELIMIT_DEFENSE_",
env_file = ".env",
env_file_encoding = "utf-8",
extra = "ignore",
)
MODE: DefenseMode = DefenseMode.ADAPTIVE
GLOBAL_LIMIT: str = "50000/minute"
CIRCUIT_THRESHOLD: Annotated[int, Field(ge = 1)] = 10000
CIRCUIT_WINDOW: Annotated[int, Field(ge = 1, le = 3600)] = 60
CIRCUIT_RECOVERY_TIME: Annotated[int, Field(ge = 1, le = 3600)] = 30
ADAPTIVE_REDUCTION_FACTOR: Annotated[float,
Field(ge = 0.1,
le = 1.0)] = 0.5
ENDPOINT_LIMIT_MULTIPLIER: Annotated[int, Field(ge = 1, le = 100)] = 10
LOCKDOWN_ALLOW_AUTHENTICATED: bool = True
LOCKDOWN_ALLOW_KNOWN_GOOD: bool = True
@model_validator(mode = "after")
def validate_global_limit(self) -> DefenseSettings:
"""
Validate global limit can be parsed.
"""
RateLimitRule.parse(self.GLOBAL_LIMIT)
return self
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"
HTTP_420_MESSAGE: str = "Enhance your calm"
HTTP_420_DETAIL: str = "Rate limit exceeded. Take a breather."
endpoint_limits: dict[str, list[RateLimitRule]] = {}
storage: StorageSettings = StorageSettings()
fingerprint: FingerprintSettings = FingerprintSettings()
defense: DefenseSettings = DefenseSettings()
@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": # noqa: SIM102
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
def get_default_rules(self) -> list[RateLimitRule]:
"""
Parse and return default rate limit rules
"""
return [
RateLimitRule.parse(limit) for limit in self.DEFAULT_LIMITS
]
def get_global_limit_rule(self) -> RateLimitRule:
"""
Parse and return global defense limit rule
"""
return RateLimitRule.parse(self.defense.GLOBAL_LIMIT)
@lru_cache
def get_settings() -> RateLimiterSettings:
"""
Cached settings instance
"""
return RateLimiterSettings()

View File

@ -0,0 +1,14 @@
"""
AngelaMos | 2025
__init__.py
"""
from fastapi_420.defense.circuit_breaker import CircuitBreaker
from fastapi_420.defense.layers import LayeredDefense, LayerResult
__all__ = [
"CircuitBreaker",
"LayerResult",
"LayeredDefense",
]

View File

@ -0,0 +1,142 @@
"""
AngelaMos | 2025
circuit_breaker.py
"""
from __future__ import annotations
import time
import asyncio
import logging
from dataclasses import (
field,
dataclass,
)
from typing import TYPE_CHECKING
from fastapi_420.types import CircuitState, DefenseMode
if TYPE_CHECKING:
from fastapi_420.storage import Storage
logger = logging.getLogger("fastapi_420")
@dataclass
class CircuitBreaker:
"""
Global circuit breaker for API-wide protection
When request volume exceeds threshold, the circuit opens
and applies configured defense strategy.
"""
threshold: int = 10000
window_seconds: int = 60
recovery_time: int = 30
defense_mode: DefenseMode = DefenseMode.ADAPTIVE
_state: CircuitState = field(default_factory = CircuitState)
_lock: asyncio.Lock = field(default_factory = asyncio.Lock)
_counter_key: str = "circuit:global:requests"
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
return False
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)
return False
return True
async def record_request(self, storage: Storage) -> None:
"""
Record a request in the circuit breaker counter
"""
now = time.time()
window = int(now // self.window_seconds)
key = f"{self._counter_key}:{window}"
await storage.increment(
key = key,
window_seconds = self.window_seconds,
limit = self.threshold * 10,
timestamp = now,
)
async def _get_request_count(self, storage: Storage) -> int:
"""
Get current request count in window
"""
now = time.time()
window = int(now // self.window_seconds)
key = f"{self._counter_key}:{window}"
state = await storage.get_window_state(
key = key,
window_seconds = self.window_seconds,
)
return state.current_count
async def _trip(self, now: float) -> None:
"""
Trip the circuit breaker
"""
self._state.is_open = True
self._state.last_failure_time = now
self._state.failure_count += 1
self._state.half_open_requests = 0
logger.warning(
"Circuit breaker tripped",
extra = {
"threshold": self.threshold,
"total_requests": self._state.total_requests_in_window,
"defense_mode": self.defense_mode.value,
},
)
async def _enter_half_open(self) -> None:
"""
Enter half-open state for recovery testing
"""
self._state.half_open_requests = 0
logger.info("Circuit breaker entering half-open state")
async def reset(self) -> None:
"""
Reset circuit breaker to closed state
"""
async with self._lock:
self._state = CircuitState()
logger.info("Circuit breaker reset to closed state")
@property
def is_open(self) -> bool:
"""
Check if circuit is open
"""
return self._state.is_open
@property
def current_state(self) -> CircuitState:
"""
Get current circuit state
"""
return self._state

View File

@ -0,0 +1,315 @@
"""
AngelaMos | 2025
layers.py
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from fastapi_420.algorithms import create_algorithm
from fastapi_420.exceptions import EnhanceYourCalm
from fastapi_420.types import (
DefenseContext,
DefenseMode,
FingerprintData,
Layer,
RateLimitKey,
RateLimitResult,
RateLimitRule,
)
if TYPE_CHECKING:
from starlette.requests import Request
from fastapi_420.config import RateLimiterSettings
from fastapi_420.defense.circuit_breaker import CircuitBreaker
from fastapi_420.storage import Storage
logger = logging.getLogger("fastapi_420")
@dataclass
class LayerResult:
"""
Result from a defense layer check
"""
layer: Layer
allowed: bool
result: RateLimitResult
should_continue: bool = True
class LayeredDefense:
"""
Three-layer defense system for comprehensive rate limiting
Layer 1: Per-User Per-Endpoint (granular)
Layer 2: Per-Endpoint Global (endpoint protection)
Layer 3: Global Circuit Breaker (DDoS protection)
"""
def __init__(
self,
storage: Storage,
settings: RateLimiterSettings,
circuit_breaker: CircuitBreaker | None = None,
) -> None:
self._storage = storage
self._settings = settings
self._circuit_breaker = circuit_breaker
self._algorithm = create_algorithm(settings.ALGORITHM)
async def check_all_layers(
self,
request: Request,
fingerprint: FingerprintData,
endpoint: str,
rules: list[RateLimitRule],
) -> RateLimitResult:
"""
Check all defense layers in order
"""
context = DefenseContext(
fingerprint = fingerprint,
endpoint = endpoint,
method = request.method,
is_authenticated = fingerprint.auth_identifier is not None,
)
layer3_result = await self._check_layer3_global(context)
if not layer3_result.allowed:
self._log_violation(layer3_result, context)
raise EnhanceYourCalm(
result = layer3_result.result,
message = self._settings.HTTP_420_MESSAGE,
detail = "API is under heavy load. Please try again later.",
)
layer2_result = await self._check_layer2_endpoint(context, rules)
if not layer2_result.allowed:
self._log_violation(layer2_result, context)
raise EnhanceYourCalm(
result = layer2_result.result,
message = self._settings.HTTP_420_MESSAGE,
detail = self._settings.HTTP_420_DETAIL,
)
layer1_result = await self._check_layer1_user(context, rules)
if not layer1_result.allowed:
self._log_violation(layer1_result, context)
raise EnhanceYourCalm(
result = layer1_result.result,
message = self._settings.HTTP_420_MESSAGE,
detail = self._settings.HTTP_420_DETAIL,
)
return layer1_result.result
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: # noqa: SIM102
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,
)
async def _check_layer2_endpoint(
self,
context: DefenseContext,
rules: list[RateLimitRule], # noqa: ARG002
) -> 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",
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,
)
if not result.allowed:
return LayerResult(
layer = Layer.ENDPOINT,
allowed = False,
result = result,
)
return LayerResult(
layer = Layer.ENDPOINT,
allowed = True,
result = result,
)
async def _check_layer3_global(
self,
context: DefenseContext,
) -> LayerResult:
"""
Layer 3: Global circuit breaker
"""
if self._circuit_breaker is None:
return LayerResult(
layer = Layer.GLOBAL,
allowed = True,
result = RateLimitResult(
allowed = True,
limit = 0,
remaining = 0,
reset_after = 0,
),
)
is_allowed = await self._circuit_breaker.check(self._storage)
if not is_allowed:
if self._should_bypass_circuit(context):
return LayerResult(
layer = Layer.GLOBAL,
allowed = True,
result = RateLimitResult(
allowed = True,
limit = 0,
remaining = 0,
reset_after = 0,
),
)
return LayerResult(
layer = Layer.GLOBAL,
allowed = False,
result = RateLimitResult(
allowed = False,
limit = self._circuit_breaker.threshold,
remaining = 0,
reset_after = float(
self._circuit_breaker.recovery_time
),
retry_after = float(
self._circuit_breaker.recovery_time
),
),
)
await self._circuit_breaker.record_request(self._storage)
return LayerResult(
layer = Layer.GLOBAL,
allowed = True,
result = RateLimitResult(
allowed = True,
limit = self._circuit_breaker.threshold,
remaining = max(
0,
self._circuit_breaker.threshold - self._circuit_breaker
.current_state.total_requests_in_window,
),
reset_after = float(self._circuit_breaker.window_seconds),
),
)
def _should_bypass_circuit(self, context: DefenseContext) -> bool:
"""
Determine if request should bypass open circuit
"""
mode = self._settings.defense.MODE
if mode == DefenseMode.DISABLED:
return True
if mode == DefenseMode.LOCKDOWN:
if self._settings.defense.LOCKDOWN_ALLOW_AUTHENTICATED and context.is_authenticated:
return True
return self._settings.defense.LOCKDOWN_ALLOW_KNOWN_GOOD and context.reputation_score >= 0.9
if mode == DefenseMode.ADAPTIVE:
return bool(context.is_authenticated)
return False
def _log_violation(
self,
layer_result: LayerResult,
context: DefenseContext,
) -> None:
"""
Log rate limit violation
"""
if not self._settings.LOG_VIOLATIONS:
return
logger.warning(
"Rate limit exceeded",
extra = {
"layer": layer_result.layer.value,
"endpoint": context.endpoint,
"method": context.method,
"is_authenticated": context.is_authenticated,
"remaining": layer_result.result.remaining,
"reset_after": layer_result.result.reset_after,
},
)

View File

@ -0,0 +1,177 @@
"""
AngelaMos | 2025
dependencies.py
"""
from __future__ import annotations
from typing import Annotated
from collections.abc import Callable
from fastapi import Depends, Request
from fastapi_420.limiter import RateLimiter
from fastapi_420.types import RateLimitResult, RateLimitRule
_global_limiter: RateLimiter | None = None
def set_global_limiter(limiter: RateLimiter) -> None:
"""
Set the global rate limiter instance for dependency injection
"""
global _global_limiter # pylint: disable=global-statement
_global_limiter = limiter
def get_limiter() -> RateLimiter:
"""
Get the global rate limiter instance
"""
if _global_limiter is None:
raise RuntimeError(
"Rate limiter not initialized. "
"Call set_global_limiter() or use RateLimiterDep with explicit limiter."
)
return _global_limiter
class RateLimitDep:
"""
FastAPI dependency for rate limiting
Usage:
@app.get("/api/data", dependencies=[Depends(RateLimitDep("100/minute"))])
async def get_data():
return {"data": "value"}
# Or with result access:
@app.get("/api/data")
async def get_data(limit_result: Annotated[RateLimitResult, Depends(RateLimitDep("100/minute"))]):
return {"remaining": limit_result.remaining}
"""
def __init__(
self,
*rules: str,
limiter: RateLimiter | None = None,
key_func: Callable[[Request],
str] | None = None,
) -> None:
self.rules = [RateLimitRule.parse(rule) for rule in rules]
self._limiter = limiter
self.key_func = key_func
@property
def limiter(self) -> RateLimiter:
"""
Get limiter instance
"""
if self._limiter is not None:
return self._limiter
return get_limiter()
async def __call__(self, request: Request) -> RateLimitResult:
"""
Check rate limit and return result
"""
rule_strings = [str(rule) for rule in self.rules]
return await self.limiter.check(
request,
*rule_strings,
key_func = self.key_func,
raise_on_limit = True,
)
def create_rate_limit_dep(
*rules: str,
limiter: RateLimiter | None = None,
key_func: Callable[[Request],
str] | None = None,
) -> RateLimitDep:
"""
Factory function to create rate limit dependency
Usage:
rate_limit = create_rate_limit_dep("100/minute", "1000/hour")
@app.get("/api/data", dependencies=[Depends(rate_limit)])
async def get_data():
return {"data": "value"}
"""
return RateLimitDep(*rules, limiter = limiter, key_func = key_func)
LimiterDep = Annotated[RateLimiter, Depends(get_limiter)]
async def require_rate_limit(
request: Request,
limiter: LimiterDep,
) -> RateLimitResult:
"""
Dependency that applies default rate limits
Usage:
@app.get("/api/data")
async def get_data(
limit_result: Annotated[RateLimitResult, Depends(require_rate_limit)]
):
return {"remaining": limit_result.remaining}
"""
return await limiter.check(request, raise_on_limit = True)
class ScopedRateLimiter:
"""
Rate limiter scoped to specific endpoints or route groups
Usage:
api_limiter = ScopedRateLimiter(
prefix="/api/v1",
default_rules=["100/minute"],
endpoint_rules={
"POST:/api/v1/upload": ["10/minute"],
"POST:/api/v1/login": ["5/minute"],
}
)
@app.post("/api/v1/upload", dependencies=[Depends(api_limiter)])
async def upload():
return {"status": "ok"}
"""
def __init__(
self,
prefix: str = "",
default_rules: list[str] | None = None,
endpoint_rules: dict[str,
list[str]] | None = None,
limiter: RateLimiter | None = None,
) -> None:
self.prefix = prefix
self.default_rules = default_rules or ["100/minute"]
self.endpoint_rules = endpoint_rules or {}
self._limiter = limiter
@property
def limiter(self) -> RateLimiter:
"""
Get limiter instance
"""
if self._limiter is not None:
return self._limiter
return get_limiter()
async def __call__(self, request: Request) -> RateLimitResult:
"""
Apply appropriate rate limit based on endpoint
"""
endpoint = f"{request.method}:{request.url.path}"
rules = self.endpoint_rules.get(endpoint, self.default_rules)
return await self.limiter.check(
request,
*rules,
raise_on_limit = True,
)

View File

@ -0,0 +1,211 @@
"""
AngelaMos | 2025
exceptions.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException
from fastapi_420.types import Layer, StorageType
if TYPE_CHECKING:
from fastapi_420.types import RateLimitResult
HTTP_420_ENHANCE_YOUR_CALM = 420
class RateLimitError(Exception):
"""
Base exception for rate limiting errors
"""
def __init__(
self,
message: str,
details: dict[str,
Any] | None = None
) -> None:
self.message = message
self.details = details or {}
super().__init__(self.message)
class EnhanceYourCalm(HTTPException):
"""
HTTP 420 response - the signature rate limit response
"""
def __init__(
self,
result: RateLimitResult | None = None,
message: str = "Enhance your calm",
detail: str = "Rate limit exceeded. Take a breather.",
headers: dict[str,
str] | None = None,
) -> None:
final_headers = {}
if result:
final_headers.update(result.headers)
if headers:
final_headers.update(headers)
super().__init__(
status_code = HTTP_420_ENHANCE_YOUR_CALM,
detail = {
"message": message,
"detail": detail,
"limit_info": result.headers if result else {}
},
headers = final_headers if final_headers else None,
)
self.result = result
class RateLimitExceeded(RateLimitError):
"""
Raised when a rate limit is exceeded at any layer
"""
def __init__(
self,
result: RateLimitResult,
layer: Layer = Layer.USER,
endpoint: str = "",
identifier: str = "",
) -> None:
self.result = result
self.layer = layer
self.endpoint = endpoint
self.identifier = identifier
super().__init__(
message = f"Rate limit exceeded on {layer.value} layer",
details = {
"layer": layer.value,
"endpoint": endpoint,
"remaining": result.remaining,
"reset_after": result.reset_after,
},
)
class StorageError(RateLimitError):
"""
Raised when storage backend operations fail
"""
def __init__(
self,
operation: str,
backend: StorageType | None = None,
original_error: Exception | None = None,
) -> None:
self.operation = operation
self.backend = backend
self.original_error = original_error
backend_name = backend.value if backend else "unknown"
super().__init__(
message =
f"Storage operation '{operation}' failed on {backend_name}",
details = {
"operation": operation,
"backend": backend_name,
"error": str(original_error) if original_error else None,
},
)
class StorageConnectionError(StorageError):
"""
Raised when unable to connect to storage backend
"""
def __init__(
self,
backend: StorageType,
original_error: Exception | None = None,
) -> None:
super().__init__(
operation = "connect",
backend = backend,
original_error = original_error,
)
class StorageUnavailable(StorageError):
"""
Raised when storage backend is temporarily unavailable
"""
def __init__(
self,
backend: StorageType,
original_error: Exception | None = None,
) -> None:
super().__init__(
operation = "health_check",
backend = backend,
original_error = original_error,
)
class FingerprintError(RateLimitError):
"""
Raised when fingerprint extraction fails
"""
def __init__(
self,
reason: str,
original_error: Exception | None = None,
) -> None:
self.reason = reason
self.original_error = original_error
super().__init__(
message = f"Fingerprint extraction failed: {reason}",
details = {
"reason": reason,
"error": str(original_error) if original_error else None,
},
)
class CircuitBreakerOpen(RateLimitError):
"""
Raised when global circuit breaker is open
"""
def __init__(
self,
recovery_time: float,
total_requests: int,
threshold: int,
) -> None:
self.recovery_time = recovery_time
self.total_requests = total_requests
self.threshold = threshold
super().__init__(
message = "Circuit breaker is open - API is in defense mode",
details = {
"recovery_time": recovery_time,
"total_requests": total_requests,
"threshold": threshold,
},
)
class ConfigurationError(RateLimitError):
"""
Raised when rate limiter configuration is invalid
"""
def __init__(self, reason: str) -> None:
super().__init__(
message = f"Invalid configuration: {reason}",
details = {"reason": reason},
)
class InvalidRuleError(ConfigurationError):
"""
Raised when a rate limit rule string is invalid
"""
def __init__(self, rule_string: str, reason: str) -> None:
self.rule_string = rule_string
super().__init__(
reason = f"Invalid rule '{rule_string}': {reason}"
)

View File

@ -0,0 +1,17 @@
"""
AngelaMos | 2025
__init__.py
"""
from fastapi_420.fingerprinting.auth import AuthExtractor
from fastapi_420.fingerprinting.composite import CompositeFingerprinter
from fastapi_420.fingerprinting.headers import HeadersExtractor
from fastapi_420.fingerprinting.ip import IPExtractor
__all__ = [
"AuthExtractor",
"CompositeFingerprinter",
"HeadersExtractor",
"IPExtractor",
]

View File

@ -0,0 +1,138 @@
"""
AngelaMos | 2025
auth.py
"""
from __future__ import annotations
import jwt
import json
import base64
import hashlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from starlette.requests import Request
class AuthExtractor:
"""
Extract authentication identifiers from requests
Supports JWT tokens, API keys in headers/query params,
and session-based authentication.
"""
def __init__(
self,
jwt_secret: str | None = None,
jwt_algorithms: list[str] | None = None,
api_key_header: str = "X-API-Key",
api_key_query_param: str = "api_key",
session_cookie: str = "session_id",
hash_identifiers: bool = True,
hash_length: int = 16,
) -> None:
self.jwt_secret = jwt_secret
self.jwt_algorithms = jwt_algorithms or ["HS256"]
self.api_key_header = api_key_header
self.api_key_query_param = api_key_query_param
self.session_cookie = session_cookie
self.hash_identifiers = hash_identifiers
self.hash_length = hash_length
def extract(self, request: Request) -> str | None:
"""
Extract authentication identifier using fallback chain
Order: JWT -> API Key (header) -> API Key (query) -> Session -> None
"""
identifier = (
self._extract_jwt_subject(request)
or self._extract_api_key_header(request)
or self._extract_api_key_query(request)
or self._extract_session(request)
)
if identifier and self.hash_identifiers:
return self._hash_identifier(identifier)
return identifier
def _extract_jwt_subject(self, request: Request) -> str | None:
"""
Extract subject claim from JWT Bearer token
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7 :]
if not self.jwt_secret:
return self._extract_jwt_subject_unsafe(token)
try:
payload = jwt.decode(
token,
self.jwt_secret,
algorithms = self.jwt_algorithms,
)
return str(payload.get("sub", ""))
except Exception:
return None
def _extract_jwt_subject_unsafe(self, token: str) -> str | None:
"""
Extract subject from JWT without verification
Used when jwt_secret is not configured - only extracts
the claim for rate limiting purposes, does NOT validate.
"""
try:
parts = token.split(".")
if len(parts) != 3:
return None
payload_b64 = parts[1]
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes)
return str(payload.get("sub", ""))
except Exception:
return None
def _extract_api_key_header(self, request: Request) -> str | None:
"""
Extract API key from header
"""
return request.headers.get(self.api_key_header)
def _extract_api_key_query(self, request: Request) -> str | None:
"""
Extract API key from query parameter
"""
return request.query_params.get(self.api_key_query_param)
def _extract_session(self, request: Request) -> str | None:
"""
Extract session ID from cookie
"""
return request.cookies.get(self.session_cookie)
def _hash_identifier(self, identifier: str) -> str:
"""
Hash identifier for privacy
"""
hash_bytes = hashlib.sha256(identifier.encode()).hexdigest()
return hash_bytes[: self.hash_length]
def is_authenticated(self, request: Request) -> bool:
"""
Check if request has any authentication
"""
return self.extract(request) is not None

View File

@ -0,0 +1,198 @@
"""
AngelaMos | 2025
composite.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi_420.fingerprinting.auth import AuthExtractor
from fastapi_420.fingerprinting.headers import HeadersExtractor
from fastapi_420.fingerprinting.ip import IPExtractor
from fastapi_420.types import FingerprintData, FingerprintLevel
if TYPE_CHECKING:
from starlette.requests import Request
from fastapi_420.config import FingerprintSettings
class CompositeFingerprinter: # pylint: disable=too-many-instance-attributes
"""
Combines multiple fingerprinting methods based on configuration
Preset levels determine which extractors are used:
- strict: All methods (IP, headers, auth, TLS, geo)
- normal: IP + User-Agent + Auth (default)
- relaxed: IP + Auth only
- custom: Configured via settings
"""
def __init__(
self,
level: FingerprintLevel = FingerprintLevel.NORMAL,
ip_extractor: IPExtractor | None = None,
headers_extractor: HeadersExtractor | None = None,
auth_extractor: AuthExtractor | None = None,
use_ip: bool = True,
use_user_agent: bool = True,
use_accept_headers: bool = False,
use_header_order: bool = False,
use_auth: bool = True,
use_tls: bool = False,
use_geo: bool = False,
) -> None:
self.level = level
self._ip_extractor = ip_extractor or IPExtractor()
self._headers_extractor = headers_extractor or HeadersExtractor(
use_header_order = use_header_order,
)
self._auth_extractor = auth_extractor or AuthExtractor()
if level == FingerprintLevel.STRICT:
self.use_ip = True
self.use_user_agent = True
self.use_accept_headers = True
self.use_header_order = True
self.use_auth = True
self.use_tls = True
self.use_geo = True
elif level == FingerprintLevel.RELAXED:
self.use_ip = True
self.use_user_agent = False
self.use_accept_headers = False
self.use_header_order = False
self.use_auth = True
self.use_tls = False
self.use_geo = False
elif level == FingerprintLevel.CUSTOM:
self.use_ip = use_ip
self.use_user_agent = use_user_agent
self.use_accept_headers = use_accept_headers
self.use_header_order = use_header_order
self.use_auth = use_auth
self.use_tls = use_tls
self.use_geo = use_geo
else:
self.use_ip = True
self.use_user_agent = True
self.use_accept_headers = False
self.use_header_order = False
self.use_auth = True
self.use_tls = False
self.use_geo = False
@classmethod
def from_settings(
cls,
settings: FingerprintSettings
) -> CompositeFingerprinter:
"""
Create fingerprinter from settings
"""
ip_extractor = IPExtractor(
ipv6_prefix_length = settings.IPV6_PREFIX_LENGTH,
trusted_proxies = settings.TRUSTED_PROXIES,
trust_x_forwarded_for = settings.TRUST_X_FORWARDED_FOR,
)
headers_extractor = HeadersExtractor(
use_header_order = settings.USE_HEADER_ORDER,
)
return cls(
level = settings.LEVEL,
ip_extractor = ip_extractor,
headers_extractor = headers_extractor,
use_ip = settings.USE_IP,
use_user_agent = settings.USE_USER_AGENT,
use_accept_headers = settings.USE_ACCEPT_HEADERS,
use_header_order = settings.USE_HEADER_ORDER,
use_auth = settings.USE_AUTH,
use_tls = settings.USE_TLS,
use_geo = settings.USE_GEO,
)
async def extract(self, request: Request) -> FingerprintData:
"""
Extract fingerprint data from request
"""
raw_ip = ""
normalized_ip = ""
user_agent = None
accept_language = None
accept_encoding = None
headers_hash = None
auth_identifier = None
tls_fingerprint = None
geo_asn = None
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)
if self.use_tls:
tls_fingerprint = self._extract_tls_fingerprint(request)
if self.use_geo:
geo_asn = self._extract_geo_asn(request)
return FingerprintData(
ip = raw_ip,
ip_normalized = normalized_ip,
user_agent = user_agent,
accept_language = accept_language,
accept_encoding = accept_encoding,
headers_hash = headers_hash,
auth_identifier = auth_identifier,
tls_fingerprint = tls_fingerprint,
geo_asn = geo_asn,
)
def _extract_tls_fingerprint(self, request: Request) -> str | None:
"""
Extract TLS/JA3 fingerprint if available
Requires proxy to pass fingerprint in header
"""
return (
request.headers.get("X-JA3-Fingerprint")
or request.headers.get("X-TLS-Fingerprint")
)
def _extract_geo_asn(self, request: Request) -> str | None:
"""
Extract geographic ASN if available
Requires geo lookup service to populate header
"""
return (
request.headers.get("X-Client-ASN")
or request.headers.get("CF-IPCountry")
)
def is_authenticated(self, request: Request) -> bool:
"""
Check if request has authentication
"""
return self._auth_extractor.is_authenticated(request)

View File

@ -0,0 +1,95 @@
"""
AngelaMos | 2025
headers.py
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING, ClassVar
if TYPE_CHECKING:
from starlette.requests import Request
class HeadersExtractor:
"""
Extract fingerprint data from HTTP headers
Header ordering is browser-specific and not user-configurable,
making it useful for fingerprinting even when other headers are spoofed.
"""
FINGERPRINT_HEADERS: ClassVar[list[str]] = [
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"connection",
"upgrade-insecure-requests",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-user",
"sec-fetch-dest",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
]
def __init__(
self,
use_header_order: bool = False,
hash_length: int = 16,
) -> None:
self.use_header_order = use_header_order
self.hash_length = hash_length
def extract_user_agent(self, request: Request) -> str | None:
"""
Extract User-Agent header
"""
return request.headers.get("user-agent")
def extract_accept_language(self, request: Request) -> str | None:
"""
Extract Accept-Language header
"""
return request.headers.get("accept-language")
def extract_accept_encoding(self, request: Request) -> str | None:
"""
Extract Accept-Encoding header
"""
return request.headers.get("accept-encoding")
def compute_headers_hash(self, request: Request) -> str:
"""
Compute hash of fingerprint-relevant headers
Includes header ordering if configured, which is
browser-specific and harder to spoof.
"""
components: list[str] = []
if self.use_header_order:
header_keys = [k.lower() for k in request.headers]
components.append("|".join(header_keys))
for header_name in self.FINGERPRINT_HEADERS:
value = request.headers.get(header_name, "")
components.append(f"{header_name}={value}")
fingerprint_string = "\n".join(components)
hash_bytes = hashlib.sha256(fingerprint_string.encode()
).hexdigest()
return hash_bytes[: self.hash_length]
def extract_all(self, request: Request) -> dict[str, str | None]:
"""
Extract all header-based fingerprint data
"""
return {
"user_agent": self.extract_user_agent(request),
"accept_language": self.extract_accept_language(request),
"accept_encoding": self.extract_accept_encoding(request),
"headers_hash": self.compute_headers_hash(request),
}

View File

@ -0,0 +1,134 @@
"""
AngelaMos | 2025
ip.py
"""
from __future__ import annotations
from ipaddress import (
IPv6Address,
ip_address,
ip_network,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from starlette.requests import Request
class IPExtractor:
"""
Extract and normalize client IP addresses
Handles IPv6 /64 prefix normalization since users control
entire /64 prefixes (~18 quintillion addresses), making
per-IP limits trivially bypassable without normalization.
"""
def __init__(
self,
ipv6_prefix_length: int = 64,
trusted_proxies: list[str] | None = None,
trust_x_forwarded_for: bool = False,
) -> None:
self.ipv6_prefix_length = ipv6_prefix_length
self.trusted_proxies = set(trusted_proxies or [])
self.trust_x_forwarded_for = trust_x_forwarded_for
def extract(self, request: Request) -> tuple[str, str]:
"""
Extract raw IP and normalized IP from request
Returns tuple of (raw_ip, normalized_ip)
"""
raw_ip = self._get_client_ip(request)
normalized_ip = self._normalize_ip(raw_ip)
return raw_ip, normalized_ip
def _get_client_ip(self, request: Request) -> str:
"""
Get client IP address, handling proxy headers if configured
"""
if self.trust_x_forwarded_for:
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return self._parse_x_forwarded_for(forwarded_for, request)
x_real_ip = request.headers.get("X-Real-IP")
if x_real_ip and self._is_trusted_proxy(
request.client.host if request.client else ""):
return x_real_ip.strip()
if request.client:
return request.client.host
return "0.0.0.0"
def _parse_x_forwarded_for(self, header: str, request: Request) -> str:
"""
Parse X-Forwarded-For header safely
Uses rightmost-trusted approach: walk backwards through
the chain and return the first IP not in trusted proxies
"""
ips = [ip.strip() for ip in header.split(",")]
if not self.trusted_proxies:
return ips[0]
for ip in reversed(ips):
if ip not in self.trusted_proxies:
return ip
return ips[0] if ips else (
request.client.host if request.client else "0.0.0.0"
)
def _is_trusted_proxy(self, ip: str) -> bool:
"""
Check if IP is in trusted proxies list
"""
return ip in self.trusted_proxies
def _normalize_ip(self, ip_str: str) -> str:
"""
Normalize IP address for rate limiting
IPv6 addresses are normalized to their /64 network prefix
since users typically control entire /64 blocks.
"""
try:
addr = ip_address(ip_str)
except ValueError:
return ip_str
if isinstance(addr, IPv6Address):
if addr.ipv4_mapped:
return str(addr.ipv4_mapped)
network = ip_network(
f"{ip_str}/{self.ipv6_prefix_length}",
strict = False,
)
return str(network.network_address)
return str(addr)
def is_ipv6(self, ip_str: str) -> bool:
"""
Check if IP string is IPv6
"""
try:
addr = ip_address(ip_str)
return isinstance(addr, IPv6Address)
except ValueError:
return False
def is_private(self, ip_str: str) -> bool:
"""
Check if IP is a private/internal address
"""
try:
addr = ip_address(ip_str)
return addr.is_private
except ValueError:
return False

View File

@ -0,0 +1,364 @@
"""
AngelaMos | 2025
limiter.py
"""
from __future__ import annotations
import asyncio
import logging
import functools
from typing import (
Any,
ParamSpec,
TypeVar,
TYPE_CHECKING,
)
from collections.abc import Callable
from starlette.requests import Request
from fastapi_420.algorithms import (
create_algorithm,
)
from fastapi_420.config import (
RateLimiterSettings,
get_settings,
)
from fastapi_420.exceptions import (
EnhanceYourCalm,
StorageConnectionError,
StorageError,
)
from fastapi_420.fingerprinting import (
CompositeFingerprinter,
)
from fastapi_420.storage import (
MemoryStorage,
RedisStorage,
create_storage,
)
from fastapi_420.types import (
Layer,
RateLimitKey,
RateLimitResult,
RateLimitRule,
)
if TYPE_CHECKING:
from fastapi_420.algorithms.base import BaseAlgorithm
from fastapi_420.storage import Storage
logger = logging.getLogger("fastapi_420")
P = ParamSpec("P")
R = TypeVar("R")
class RateLimiter:
"""
Main rate limiter class for FastAPI applications.
Usage:
limiter = RateLimiter()
@app.get("/api/data")
@limiter.limit("100/minute", "1000/hour")
async def get_data(request: Request):
return {"data": "value"}
"""
def __init__(
self,
settings: RateLimiterSettings | None = None,
storage: Storage | None = None,
) -> None:
self._settings = settings or get_settings()
self._storage = storage
self._fallback_storage: MemoryStorage | None = None
self._algorithm: BaseAlgorithm | None = None
self._fingerprinter: CompositeFingerprinter | None = None
self._initialized = False
self._lock = asyncio.Lock()
async def init(self) -> None:
"""
Initialize storage, algorithm, and fingerprinter
"""
async with self._lock:
if self._initialized:
return
if self._storage is None:
self._storage = create_storage(self._settings.storage)
if self._settings.storage.FALLBACK_TO_MEMORY:
self._fallback_storage = MemoryStorage.from_settings(
self._settings.storage
)
await self._fallback_storage.start_cleanup_task()
if isinstance(self._storage, RedisStorage):
try:
await self._storage.connect()
except StorageConnectionError:
if self._settings.FAIL_OPEN and self._fallback_storage:
logger.warning(
"Redis unavailable, using memory fallback",
extra = {
"redis_url":
self._settings.storage.REDIS_URL
},
)
self._storage = self._fallback_storage
self._fallback_storage = None
else:
raise
if isinstance(self._storage,
MemoryStorage
) and self._storage != self._fallback_storage:
await self._storage.start_cleanup_task()
self._algorithm = create_algorithm(self._settings.ALGORITHM)
self._fingerprinter = CompositeFingerprinter.from_settings(
self._settings.fingerprint
)
self._initialized = True
logger.info(
"Rate limiter initialized",
extra = {
"algorithm":
self._settings.ALGORITHM.value,
"storage":
self._storage.storage_type.value,
"fingerprint_level":
self._settings.fingerprint.LEVEL.value,
},
)
async def close(self) -> None:
"""
Close storage connections
"""
if self._storage:
await self._storage.close()
if self._fallback_storage:
await self._fallback_storage.close()
self._initialized = False
def limit(
self,
*rules: str,
key_func: Callable[[Request],
str] | None = None,
) -> Callable[[Callable[P,
R]],
Callable[P,
R]]:
"""
Decorator to apply rate limits to an endpoint
Args:
rules: Rate limit strings like "100/minute", "1000/hour"
key_func: Optional custom function to generate rate limit key
"""
parsed_rules = [RateLimitRule.parse(rule) for rule in rules]
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
request = self._extract_request(args, kwargs)
if request is None:
return await func(*args, **kwargs) # type: ignore[misc, no-any-return]
await self._check_rate_limits(
request = request,
rules = parsed_rules,
key_func = key_func,
)
return await func(*args, **kwargs) # type: ignore[misc, no-any-return]
return wrapper # type: ignore[return-value]
return decorator
async def check(
self,
request: Request,
*rules: str,
key_func: Callable[[Request],
str] | None = None,
raise_on_limit: bool = True,
) -> RateLimitResult:
"""
Manually check rate limit without decorator
Returns the result of the strictest (most restrictive) rule
"""
parsed_rules = [RateLimitRule.parse(rule) for rule in rules]
if not parsed_rules:
parsed_rules = self._settings.get_default_rules()
return await self._check_rate_limits(
request = request,
rules = parsed_rules,
key_func = key_func,
raise_on_limit = raise_on_limit,
)
async def _check_rate_limits(
self,
request: Request,
rules: list[RateLimitRule],
key_func: Callable[[Request],
str] | None = None,
raise_on_limit: bool = True,
) -> RateLimitResult:
"""
Check all rules and return/raise for the most restrictive failure
"""
if not self._initialized:
await self.init()
storage = await self._get_active_storage()
if storage is None:
if self._settings.FAIL_OPEN:
return RateLimitResult(
allowed = True,
limit = 0,
remaining = 0,
reset_after = 0,
)
raise StorageError(operation = "check", backend = None)
fingerprint = await self._fingerprinter.extract(request) # type: ignore[union-attr]
endpoint = self._get_endpoint(request)
if key_func:
identifier = key_func(request)
else:
identifier = 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 = endpoint,
identifier = identifier,
window = rule.window_seconds,
).build()
result = await self._algorithm.check( # type: ignore[union-attr]
storage = storage,
key = key,
rule = rule,
)
if not result.allowed: # noqa: SIM102
if worst_result is None or result.retry_after > (worst_result.retry_after or 0): # type: ignore[operator]
worst_result = result
if worst_result is not None:
if self._settings.LOG_VIOLATIONS:
logger.warning(
"Rate limit exceeded",
extra = {
"endpoint": endpoint,
"identifier": identifier[: 16],
"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
best_result = result
return best_result
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: # noqa: S110
pass
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
def _extract_request(
self,
args: tuple[Any,
...],
kwargs: dict[str,
Any],
) -> Request | None:
"""
Extract Request object from function arguments
"""
for arg in args:
if isinstance(arg, Request):
return arg
for value in kwargs.values():
if isinstance(value, Request):
return value
return None
def _get_endpoint(self, request: Request) -> str:
"""
Get endpoint identifier from request
"""
route = request.scope.get("route")
if route:
return f"{request.method}:{route.path}"
return f"{request.method}:{request.url.path}"
@property
def settings(self) -> RateLimiterSettings:
"""
Get current settings
"""
return self._settings
@property
def is_initialized(self) -> bool:
"""
Check if limiter is initialized
"""
return self._initialized

View File

@ -0,0 +1,208 @@
"""
AngelaMos | 2025
middleware.py
"""
from __future__ import annotations
import re
import logging
import asyncio
from typing import (
TYPE_CHECKING,
)
from collections.abc import Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import (
JSONResponse,
Response,
)
from fastapi_420.exceptions import (
HTTP_420_ENHANCE_YOUR_CALM,
EnhanceYourCalm,
)
from fastapi_420.limiter import RateLimiter
if TYPE_CHECKING:
from starlette.types import ASGIApp
logger = logging.getLogger("fastapi_420")
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
ASGI middleware for automatic rate limiting on all routes
Usage:
from fastapi import FastAPI
from fastapi_420.middleware import RateLimitMiddleware
from fastapi_420.limiter import RateLimiter
app = FastAPI()
limiter = RateLimiter()
app.add_middleware(
RateLimitMiddleware,
limiter=limiter,
default_limit="100/minute",
)
"""
def __init__(
self,
app: ASGIApp,
limiter: RateLimiter,
default_limit: str = "100/minute",
exclude_paths: list[str] | None = None,
exclude_patterns: list[str] | None = None,
include_paths: list[str] | None = None,
path_limits: dict[str,
str] | None = None,
key_func: Callable[[Request],
str] | None = None,
) -> None:
super().__init__(app)
self.limiter = limiter
self.default_limit = default_limit
self.exclude_paths = set(exclude_paths or [])
self.exclude_patterns = [
re.compile(p) for p in (exclude_patterns or [])
]
self.include_paths = set(include_paths) if include_paths else None
self.path_limits = path_limits or {}
self.key_func = key_func
self.exclude_paths.add("/health")
self.exclude_paths.add("/healthz")
self.exclude_paths.add("/ready")
self.exclude_paths.add("/metrics")
async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[type-arg]
"""
Process request and apply rate limiting
"""
if not await self._should_limit(request):
return await call_next(request) # type: ignore[no-any-return]
limit = self._get_limit_for_path(request.url.path)
result = await self.limiter.check(
request,
limit,
key_func = self.key_func,
raise_on_limit = False,
)
if not result.allowed:
exc = EnhanceYourCalm(
result = result,
message = self.limiter.settings.HTTP_420_MESSAGE,
detail = self.limiter.settings.HTTP_420_DETAIL,
)
return self._create_420_response(exc)
response = await call_next(request)
if self.limiter.settings.INCLUDE_HEADERS:
for header_name, header_value in result.headers.items():
response.headers[header_name] = header_value
return response # type: ignore[no-any-return]
async def _should_limit(self, request: Request) -> bool:
"""
Determine if request should be rate limited
"""
path = request.url.path
if path in self.exclude_paths:
return False
for pattern in self.exclude_patterns:
if pattern.match(path):
return False
if self.include_paths is not None: # noqa: SIM102
if path not in self.include_paths:
for include_path in self.include_paths:
if path.startswith(include_path):
break
else:
return False
return True
def _get_limit_for_path(self, path: str) -> str:
"""
Get rate limit for specific path
"""
if path in self.path_limits:
return self.path_limits[path]
for pattern_path, limit in self.path_limits.items():
if path.startswith(pattern_path):
return limit
return self.default_limit
def _create_420_response(self, exc: EnhanceYourCalm) -> JSONResponse:
"""
Create HTTP 420 response
"""
headers = {}
if exc.result:
headers.update(exc.result.headers)
return JSONResponse(
status_code = HTTP_420_ENHANCE_YOUR_CALM,
content = exc.detail,
headers = headers,
)
class SlowDownMiddleware(BaseHTTPMiddleware):
"""
Alternative middleware that adds delays instead of blocking
Useful for gradual throttling rather than hard limits
"""
def __init__(
self,
app: ASGIApp,
limiter: RateLimiter,
threshold_limit: str = "50/minute",
max_delay_seconds: float = 5.0,
delay_increment: float = 0.5,
) -> None:
super().__init__(app)
self.limiter = limiter
self.threshold_limit = threshold_limit
self.max_delay_seconds = max_delay_seconds
self.delay_increment = delay_increment
async def dispatch(self, request: Request, call_next: Callable) -> Response: # type: ignore[type-arg]
"""
Process request with potential delays
"""
result = await self.limiter.check(
request,
self.threshold_limit,
raise_on_limit = False,
)
if result.remaining <= 0:
delay = min(
self.max_delay_seconds,
result.retry_after or self.delay_increment,
)
await asyncio.sleep(delay)
response = await call_next(request)
if self.limiter.settings.INCLUDE_HEADERS:
for header_name, header_value in result.headers.items():
response.headers[header_name] = header_value
return response # type: ignore[no-any-return]

View File

@ -0,0 +1,35 @@
"""
AngelaMos | 2025
__init__.py
"""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeAlias
from fastapi_420.storage.memory import MemoryStorage
from fastapi_420.storage.redis_backend import RedisStorage
from fastapi_420.types import StorageType
if TYPE_CHECKING:
from fastapi_420.config import StorageSettings
Storage: TypeAlias = MemoryStorage | RedisStorage # noqa: UP040
def create_storage(settings: StorageSettings) -> Storage:
"""
Factory function to create appropriate storage backend.
"""
if settings.REDIS_URL is not None:
return RedisStorage.from_settings(settings)
return MemoryStorage.from_settings(settings)
__all__ = [
"MemoryStorage",
"RedisStorage",
"Storage",
"StorageType",
"create_storage",
]

View File

@ -0,0 +1,32 @@
--[[
AngelaMos | 2025
fixed_window.lua
Atomic fixed window counter rate limiting.
Simple but has boundary burst problem - use sliding_window for production.
Returns: {allowed (0/1), remaining, reset_after, retry_after}
--]]
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 window_key = key .. ":" .. current_window
local count = tonumber(redis.call('GET', window_key)) or 0
local reset_after = window_seconds - (now % window_seconds)
if count >= limit then
return {0, 0, reset_after, reset_after}
end
local new_count = redis.call('INCR', window_key)
if new_count == 1 then
redis.call('EXPIRE', window_key, window_seconds)
end
local remaining = math.max(0, limit - new_count)
return {1, remaining, reset_after, 0}

View File

@ -0,0 +1,37 @@
--[[
AngelaMos | 2025
sliding_window.lua
Atomic sliding window counter rate limiting.
Returns: {allowed (0/1), remaining, reset_after}
--]]
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}
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}

View File

@ -0,0 +1,49 @@
--[[
AngelaMos | 2025
token_bucket.lua
Atomic token bucket rate limiting.
Returns: {allowed (0/1), remaining, reset_after, retry_after}
--]]
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_to_consume = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket_key = key .. ":bucket"
local data = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_refill = now
end
local elapsed = now - last_refill
local tokens_to_add = elapsed * refill_rate
tokens = math.min(capacity, tokens + tokens_to_add)
if tokens >= tokens_to_consume then
tokens = tokens - tokens_to_consume
redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', bucket_key, 3600)
local time_to_full = 0
if refill_rate > 0 then
time_to_full = (capacity - tokens) / refill_rate
end
return {1, math.floor(tokens), time_to_full, 0}
end
redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', bucket_key, 3600)
local tokens_needed = tokens_to_consume - tokens
local wait_time = tokens_needed / refill_rate
return {0, 0, wait_time, wait_time}

View File

@ -0,0 +1,294 @@
"""
AngelaMos | 2025
memory.py
"""
from __future__ import annotations
import asyncio
import contextlib
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from fastapi_420.types import (
RateLimitResult,
StorageType,
TokenBucketState,
WindowState,
)
if TYPE_CHECKING:
from fastapi_420.config import StorageSettings
@dataclass
class WindowEntry:
"""
Storage entry for sliding window counter.
"""
count: int = 0
window_start: int = 0
expires_at: float = 0.0
@dataclass
class MemoryStorage:
"""
In memory storage backend for rate limiting.
Thread safe through asyncio locks. Suitable for single instance
deployments, development, and as a fallback when Redis is unavailable
"""
max_keys: int = 100_000
cleanup_interval: int = 60
_windows: OrderedDict[str,
WindowEntry] = field(
default_factory = OrderedDict
)
_buckets: dict[str, TokenBucketState] = field(default_factory = dict)
_lock: asyncio.Lock = field(default_factory = asyncio.Lock)
_cleanup_task: asyncio.Task[None] | None = field(
default = None,
repr = False
)
_closed: bool = field(default = False)
@classmethod
def from_settings(cls, settings: StorageSettings) -> MemoryStorage:
"""
Create storage instance from settings
"""
return cls(
max_keys = settings.MEMORY_MAX_KEYS,
cleanup_interval = settings.MEMORY_CLEANUP_INTERVAL,
)
async def start_cleanup_task(self) -> None:
"""
Start background cleanup task for expired entries
"""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
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()
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]
async def _enforce_max_keys(self) -> None:
"""
Evict oldest entries when max_keys is exceeded
"""
while len(self._windows) > self.max_keys:
self._windows.popitem(last = False)
async def get_window_state(
self,
key: str,
window_seconds: int,
) -> WindowState:
"""
Get current sliding window state
"""
now = time.time()
current_window = int(now // window_seconds)
previous_window = current_window - 1
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)
return WindowState(
current_count = current_entry.count
if current_entry else 0,
previous_count = previous_entry.count
if previous_entry else 0,
current_window = current_window,
window_seconds = window_seconds,
)
async def increment(
self,
key: str,
window_seconds: int,
limit: int,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Atomically check and increment counter using sliding window algorithm
"""
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()
new_weighted = int(
previous_count * (1 - elapsed_ratio) + current_count + 1
)
remaining = max(0, limit - new_weighted)
reset_after = window_seconds - (now % window_seconds)
return RateLimitResult(
allowed = True,
limit = limit,
remaining = remaining,
reset_after = reset_after,
)
async def get_token_bucket_state(
self,
key: str,
) -> TokenBucketState | None:
"""
Get token bucket state if it exists
"""
async with self._lock:
return self._buckets.get(key)
async def consume_token(
self,
key: str,
capacity: int,
refill_rate: float,
tokens_to_consume: int = 1,
) -> RateLimitResult:
"""
Attempt to consume tokens from bucket
"""
now = time.time()
async with self._lock:
state = self._buckets.get(key)
if state is None:
state = TokenBucketState(
tokens = float(capacity),
last_refill = now,
capacity = capacity,
refill_rate = refill_rate,
)
self._buckets[key] = state
elapsed = now - state.last_refill
tokens_to_add = elapsed * refill_rate
state.tokens = min(
float(capacity),
state.tokens + tokens_to_add
)
state.last_refill = now
if state.tokens >= tokens_to_consume:
state.tokens -= tokens_to_consume
time_to_full = (
capacity - state.tokens
) / refill_rate if refill_rate > 0 else 0
return RateLimitResult(
allowed = True,
limit = capacity,
remaining = int(state.tokens),
reset_after = time_to_full,
)
tokens_needed = tokens_to_consume - state.tokens
wait_time = tokens_needed / refill_rate if refill_rate > 0 else float(
"inf"
)
return RateLimitResult(
allowed = False,
limit = capacity,
remaining = 0,
reset_after = wait_time,
retry_after = wait_time,
)
async def close(self) -> None:
"""
Close storage and cleanup resources
"""
self._closed = True
if self._cleanup_task:
self._cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._cleanup_task
self._cleanup_task = None
async with self._lock:
self._windows.clear()
self._buckets.clear()
async def health_check(self) -> bool:
"""
Check if storage is healthy
"""
return not self._closed
@property
def storage_type(self) -> StorageType:
"""
Return storage type identifier
"""
return StorageType.MEMORY

View File

@ -0,0 +1,408 @@
"""
AngelaMos | 2025
redis_backend.py
"""
from __future__ import annotations
import time
from dataclasses import (
dataclass,
field,
)
from pathlib import Path
from typing import TYPE_CHECKING, Any
import redis.asyncio as redis
from redis.asyncio.connection import ConnectionPool
from redis.exceptions import (
ConnectionError as RedisConnectionError,
)
from redis.exceptions import (
ResponseError,
TimeoutError,
)
from fastapi_420.exceptions import (
StorageConnectionError,
StorageError,
)
from fastapi_420.types import (
RateLimitResult,
StorageType,
TokenBucketState,
WindowState,
)
if TYPE_CHECKING:
from fastapi_420.config import StorageSettings
LUA_SCRIPTS_DIR = Path(__file__).parent / "lua"
@dataclass
class RedisStorage:
"""
Redis storage backend with atomic Lua script operations
Uses EVALSHA with cached script hashes for optimal performance
All rate limiting operations are atomic to prevent race conditions
"""
url: str
max_connections: int = 100
socket_timeout: float = 5.0
retry_on_timeout: bool = True
decode_responses: bool = True
_client: redis.Redis[Any] | None = field(default = None, repr = False)
_pool: ConnectionPool[Any] | None = field(default = None, repr = False)
_script_shas: dict[str,
str] = field(
default_factory = dict,
repr = False
)
_scripts_loaded: bool = field(default = False, repr = False)
@classmethod
def from_settings(cls, settings: StorageSettings) -> RedisStorage:
"""
Create storage instance from settings
"""
if settings.REDIS_URL is None:
raise StorageConnectionError(
backend = StorageType.REDIS,
original_error = ValueError("REDIS_URL is required"),
)
return cls(
url = str(settings.REDIS_URL),
max_connections = settings.REDIS_MAX_CONNECTIONS,
socket_timeout = settings.REDIS_SOCKET_TIMEOUT,
retry_on_timeout = settings.REDIS_RETRY_ON_TIMEOUT,
decode_responses = settings.REDIS_DECODE_RESPONSES,
)
async def connect(self) -> None:
"""
Establish Redis connection and load Lua scripts.
"""
try:
self._pool = ConnectionPool.from_url(
self.url,
max_connections = self.max_connections,
socket_timeout = self.socket_timeout,
retry_on_timeout = self.retry_on_timeout,
decode_responses = self.decode_responses,
)
self._client = redis.Redis(connection_pool = self._pool)
await self._client.ping()
await self._load_scripts()
except (RedisConnectionError, TimeoutError, OSError) as e:
raise StorageConnectionError(
backend = StorageType.REDIS,
original_error = e,
) from e
async def _load_scripts(self) -> None:
"""
Load Lua scripts into Redis and cache their SHA1 hashes
"""
if self._client is None:
raise StorageError(
operation = "load_scripts",
backend = StorageType.REDIS,
original_error = RuntimeError("Client not connected"),
)
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) # type: ignore[no-untyped-call]
self._script_shas[name] = sha
self._scripts_loaded = True
async def _ensure_connected(self) -> redis.Redis[Any]:
"""
Ensure client is connected and scripts are loaded
"""
if self._client is None:
await self.connect()
if self._client is None:
raise StorageError(
operation = "ensure_connected",
backend = StorageType.REDIS,
original_error = RuntimeError(
"Failed to establish connection"
),
)
return self._client
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()
if script_name not in self._script_shas:
raise StorageError(
operation = "execute_script",
backend = StorageType.REDIS,
original_error = ValueError(
f"Unknown script: {script_name}"
),
)
sha = self._script_shas[script_name]
try:
result = await client.evalsha(sha, len(keys), *keys, *args) # type: ignore[no-untyped-call]
return result # type: ignore[no-any-return]
except ResponseError as e:
if "NOSCRIPT" in str(e):
await self._load_scripts()
sha = self._script_shas[script_name]
result = await client.evalsha(sha, len(keys), *keys, *args) # type: ignore[no-untyped-call]
return result # type: ignore[no-any-return]
raise StorageError(
operation = "execute_script",
backend = StorageType.REDIS,
original_error = e,
) from e
except (RedisConnectionError, TimeoutError) as e:
raise StorageError(
operation = "execute_script",
backend = StorageType.REDIS,
original_error = e,
) from e
async def get_window_state(
self,
key: str,
window_seconds: int,
) -> WindowState:
"""
Get current sliding window state
"""
client = await self._ensure_connected()
now = time.time()
current_window = int(now // window_seconds)
previous_window = current_window - 1
current_key = f"{key}:{current_window}"
previous_key = f"{key}:{previous_window}"
try:
pipeline = client.pipeline()
pipeline.get(current_key)
pipeline.get(previous_key)
results = await pipeline.execute()
current_count = int(results[0]) if results[0] else 0
previous_count = int(results[1]) if results[1] else 0
return WindowState(
current_count = current_count,
previous_count = previous_count,
current_window = current_window,
window_seconds = window_seconds,
)
except (RedisConnectionError, TimeoutError) as e:
raise StorageError(
operation = "get_window_state",
backend = StorageType.REDIS,
original_error = e,
) from e
async def increment(
self,
key: str,
window_seconds: int,
limit: int,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Atomically check and increment counter using sliding window algorithm
"""
now = timestamp if timestamp is not None else time.time()
result = await self._execute_script(
"sliding_window",
keys = [key],
args = [window_seconds,
limit,
now],
)
allowed = bool(result[0])
remaining = int(result[1])
reset_after = float(result[2])
retry_after = float(result[3]) if result[3] else None
return RateLimitResult(
allowed = allowed,
limit = limit,
remaining = remaining,
reset_after = reset_after,
retry_after = retry_after if not allowed else None,
)
async def increment_fixed_window(
self,
key: str,
window_seconds: int,
limit: int,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Atomically check and increment counter using fixed window algorithm
"""
now = timestamp if timestamp is not None else time.time()
result = await self._execute_script(
"fixed_window",
keys = [key],
args = [window_seconds,
limit,
now],
)
allowed = bool(result[0])
remaining = int(result[1])
reset_after = float(result[2])
retry_after = float(result[3]) if result[3] else None
return RateLimitResult(
allowed = allowed,
limit = limit,
remaining = remaining,
reset_after = reset_after,
retry_after = retry_after if not allowed else None,
)
async def get_token_bucket_state(
self,
key: str,
) -> TokenBucketState | None:
"""
Get token bucket state if it exists
"""
client = await self._ensure_connected()
bucket_key = f"{key}:bucket"
try:
data = await client.hmget(
bucket_key,
"tokens",
"last_refill",
"capacity",
"refill_rate"
)
if data[0] is None:
return None
return TokenBucketState(
tokens = float(data[0]),
last_refill = float(data[1]), # type: ignore[arg-type]
capacity = int(data[2]), # type: ignore[arg-type]
refill_rate = float(data[3]), # type: ignore[arg-type]
)
except (RedisConnectionError, TimeoutError) as e:
raise StorageError(
operation = "get_token_bucket_state",
backend = StorageType.REDIS,
original_error = e,
) from e
async def consume_token(
self,
key: str,
capacity: int,
refill_rate: float,
tokens_to_consume: int = 1,
) -> RateLimitResult:
"""
Attempt to consume tokens from bucket atomically
"""
now = time.time()
result = await self._execute_script(
"token_bucket",
keys = [key],
args = [capacity,
refill_rate,
tokens_to_consume,
now],
)
allowed = bool(result[0])
remaining = int(result[1])
reset_after = float(result[2])
retry_after = float(result[3]) if result[3] else None
return RateLimitResult(
allowed = allowed,
limit = capacity,
remaining = remaining,
reset_after = reset_after,
retry_after = retry_after if not allowed else None,
)
async def close(self) -> None:
"""
Close Redis connection.
"""
if self._client:
await self._client.close()
self._client = None
if self._pool:
await self._pool.disconnect()
self._pool = None
self._scripts_loaded = False
self._script_shas.clear()
async def health_check(self) -> bool:
"""
Check if Redis connection is healthy
"""
try:
client = await self._ensure_connected()
await client.ping()
return True
except (StorageError, RedisConnectionError, TimeoutError):
return False
@property
def storage_type(self) -> StorageType:
"""
Return storage type identifier
"""
return StorageType.REDIS
@property
def is_connected(self) -> bool:
"""
Check if client is connected
"""
return self._client is not None and self._scripts_loaded

View File

@ -0,0 +1,401 @@
"""
AngelaMos | 2025
types.py
"""
# pylint: disable=unnecessary-ellipsis
from __future__ import annotations
from enum import StrEnum
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
Protocol,
runtime_checkable,
)
if TYPE_CHECKING:
from starlette.requests import Request
class Algorithm(StrEnum):
"""
Rate limiting algorithm selection
"""
SLIDING_WINDOW = "sliding_window"
TOKEN_BUCKET = "token_bucket"
FIXED_WINDOW = "fixed_window"
LEAKY_BUCKET = "leaky_bucket"
class FingerprintLevel(StrEnum):
"""
Preset fingerprinting intensity levels
"""
STRICT = "strict"
NORMAL = "normal"
RELAXED = "relaxed"
CUSTOM = "custom"
class DefenseMode(StrEnum):
"""
Global circuit breaker defense strategies
"""
ADAPTIVE = "adaptive"
LOCKDOWN = "lockdown"
CHALLENGE = "challenge"
DISABLED = "disabled"
class StorageType(StrEnum):
"""
Backend storage type selection
"""
REDIS = "redis"
MEMORY = "memory"
class Layer(StrEnum):
"""
Rate limiting defense layers
"""
USER = "user"
ENDPOINT = "endpoint"
GLOBAL = "global"
@dataclass(frozen = True, slots = True)
class RateLimitResult:
"""
Result of a rate limit check operation
"""
allowed: bool
limit: int
remaining: int
reset_after: float
retry_after: float | None = None
@property
def headers(self) -> dict[str, str]:
"""
Generate IETF draft-compliant rate limit headers
"""
hdrs = {
"RateLimit-Limit": str(self.limit),
"RateLimit-Remaining": str(max(0,
self.remaining)),
"RateLimit-Reset": str(int(self.reset_after)),
}
if self.retry_after is not None:
hdrs["Retry-After"] = str(int(self.retry_after))
return hdrs
@dataclass(frozen = True, slots = True)
class RateLimitRule:
"""
A single rate limit rule defining requests allowed per time window
"""
requests: int
window_seconds: int
def __post_init__(self) -> None:
if self.requests <= 0:
raise ValueError("requests must be positive")
if self.window_seconds <= 0:
raise ValueError("window_seconds must be positive")
@classmethod
def parse(cls, rule_string: str) -> RateLimitRule:
"""
Parse rate limit string like '100/minute' or '1000/hour'
"""
parts = rule_string.strip().lower().split("/")
if len(parts) != 2:
raise ValueError(f"Invalid rate limit format: {rule_string}")
try:
requests = int(parts[0])
except ValueError as e:
raise ValueError(f"Invalid request count: {parts[0]}") from e
window_map = {
"second": 1,
"seconds": 1,
"sec": 1,
"s": 1,
"minute": 60,
"minutes": 60,
"min": 60,
"m": 60,
"hour": 3600,
"hours": 3600,
"hr": 3600,
"h": 3600,
"day": 86400,
"days": 86400,
"d": 86400,
}
window_str = parts[1].strip()
if window_str not in window_map:
raise ValueError(f"Unknown time unit: {window_str}")
return cls(
requests = requests,
window_seconds = window_map[window_str]
)
def __str__(self) -> str:
if self.window_seconds == 1:
unit = "second"
elif self.window_seconds == 60:
unit = "minute"
elif self.window_seconds == 3600:
unit = "hour"
elif self.window_seconds == 86400:
unit = "day"
else:
unit = f"{self.window_seconds}s"
return f"{self.requests}/{unit}"
@dataclass(slots = True)
class FingerprintData:
"""
Collected fingerprint data from a request
"""
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
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)
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)
@dataclass(slots = True)
class WindowState:
"""
State for sliding window counter algorithm
"""
current_count: int = 0
previous_count: int = 0
current_window: int = 0
window_seconds: int = 60
def weighted_count(self, elapsed_ratio: float) -> float:
"""
Calculate weighted count using sliding window interpolation.
"""
return self.previous_count * (
1 - elapsed_ratio
) + self.current_count
@dataclass(slots = True)
class TokenBucketState:
"""
State for token bucket algorithm
"""
tokens: float
last_refill: float
capacity: int
refill_rate: float
@dataclass(slots = True)
class CircuitState:
"""
Global circuit breaker state
"""
is_open: bool = False
failure_count: int = 0
last_failure_time: float = 0.0
half_open_requests: int = 0
total_requests_in_window: int = 0
@dataclass(slots = True)
class DefenseContext:
"""
Context passed to defense strategies
"""
fingerprint: FingerprintData
endpoint: str
method: str
is_authenticated: bool = False
reputation_score: float = 1.0
request_count_last_minute: int = 0
@dataclass(slots = True)
class RateLimitKey:
"""
Structured rate limit key components
"""
prefix: str = "ratelimit"
version: str = "v1"
layer: Layer = Layer.USER
endpoint: str = ""
identifier: str = ""
window: int = 0
def build(self) -> str:
"""
Build the full Redis key string.
"""
return f"{self.prefix}:{self.version}:{self.layer.value}:{self.endpoint}:{self.identifier}:{self.window}"
@runtime_checkable
class StorageBackend(Protocol):
"""
Protocol for rate limit storage backends
"""
async def get_window_state(
self,
key: str,
window_seconds: int,
) -> WindowState:
"""
Get current sliding window state
"""
...
async def increment(
self,
key: str,
window_seconds: int,
limit: int,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Atomically check and increment counter, returning result
"""
...
async def get_token_bucket_state(
self,
key: str,
) -> TokenBucketState | None:
"""
Get token bucket state if it exists
"""
...
async def consume_token(
self,
key: str,
capacity: int,
refill_rate: float,
tokens_to_consume: int = 1,
) -> RateLimitResult:
"""
Attempt to consume tokens from bucket
"""
...
async def close(self) -> None:
"""
Close storage connections.
"""
...
async def health_check(self) -> bool:
"""
Check if storage backend is healthy
"""
...
@runtime_checkable
class Fingerprinter(Protocol):
"""
Protocol for request fingerprinting strategies.
"""
async def extract(self, request: Request) -> FingerprintData:
"""
Extract fingerprint data from request.
"""
...
@runtime_checkable
class RateLimitAlgorithm(Protocol):
"""
Protocol for rate limiting algorithms
"""
async def check(
self,
storage: StorageBackend,
key: str,
rule: RateLimitRule,
timestamp: float | None = None,
) -> RateLimitResult:
"""
Check if request is allowed under rate limit
"""
...
@dataclass
class LimiterConfig:
"""
Main configuration for the rate limiter.
"""
default_rules: list[RateLimitRule] = field(default_factory = list)
algorithm: Algorithm = Algorithm.SLIDING_WINDOW
fingerprint_level: FingerprintLevel = FingerprintLevel.NORMAL
defense_mode: DefenseMode = DefenseMode.ADAPTIVE
fail_open: bool = True
key_prefix: str = "ratelimit"
include_headers: bool = True
log_violations: bool = True
global_limit: RateLimitRule | None = None
endpoint_limits: dict[str,
list[RateLimitRule]] = field(
default_factory = dict
)
def __post_init__(self) -> None:
if not self.default_rules:
self.default_rules = [
RateLimitRule.parse("100/minute"),
RateLimitRule.parse("1000/hour"),
]

View File

@ -0,0 +1,4 @@
"""
AngelaMos | 2025
__init__.py
"""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,438 @@
"""
AngelaMos | 2025
test_algorithms.py
"""
from __future__ import annotations
import time
import pytest
from fastapi_420.algorithms import create_algorithm
from fastapi_420.algorithms.sliding_window import SlidingWindowAlgorithm
from fastapi_420.algorithms.token_bucket import TokenBucketAlgorithm
from fastapi_420.algorithms.fixed_window import FixedWindowAlgorithm
from fastapi_420.storage import MemoryStorage
from fastapi_420.types import Algorithm
from tests.conftest import (
WINDOW_MINUTE,
WINDOW_SECOND,
RuleFactory,
)
class TestAlgorithmFactory:
"""
Tests for algorithm factory function
"""
def test_create_sliding_window(self) -> None:
algo = create_algorithm(Algorithm.SLIDING_WINDOW)
assert isinstance(algo, SlidingWindowAlgorithm)
def test_create_token_bucket(self) -> None:
algo = create_algorithm(Algorithm.TOKEN_BUCKET)
assert isinstance(algo, TokenBucketAlgorithm)
def test_create_fixed_window(self) -> None:
algo = create_algorithm(Algorithm.FIXED_WINDOW)
assert isinstance(algo, FixedWindowAlgorithm)
def test_create_from_string(self) -> None:
algo = create_algorithm(Algorithm.SLIDING_WINDOW)
assert algo.name == Algorithm.SLIDING_WINDOW.value
class TestSlidingWindowAlgorithm:
"""
Tests for sliding window counter algorithm
"""
@pytest.mark.asyncio
async def test_algorithm_name(self) -> None:
algo = SlidingWindowAlgorithm()
assert algo.name == "sliding_window"
@pytest.mark.asyncio
async def test_first_request_allowed(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
result = await algo.check(
storage = storage,
key = "test_key",
rule = rule,
)
assert result.allowed is True
assert result.remaining == 99
await storage.close()
@pytest.mark.asyncio
async def test_multiple_requests(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
for i in range(50):
result = await algo.check(storage, f"multi_key", rule)
assert result.allowed is True
assert result.remaining == 50
await storage.close()
@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()
@pytest.mark.asyncio
async def test_different_keys_independent(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for _ in range(5):
await algo.check(storage, "key_a", rule)
result_a = await algo.check(storage, "key_a", rule)
result_b = await algo.check(storage, "key_b", rule)
assert result_a.allowed is False
assert result_b.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_empty(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute()
usage = await algo.get_current_usage(storage, "empty_key", rule)
assert usage == 0
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_after_requests(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
for _ in range(25):
await algo.check(storage, "usage_key", rule)
usage = await algo.get_current_usage(storage, "usage_key", rule)
assert usage >= 24
await storage.close()
@pytest.mark.asyncio
async def test_explicit_timestamp(self) -> None:
algo = SlidingWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
fixed_time = 1000000.0
result = await algo.check(
storage = storage,
key = "timestamp_key",
rule = rule,
timestamp = fixed_time,
)
assert result.allowed is True
await storage.close()
class TestTokenBucketAlgorithm:
"""
Tests for token bucket algorithm
"""
@pytest.mark.asyncio
async def test_algorithm_name(self) -> None:
algo = TokenBucketAlgorithm()
assert algo.name == "token_bucket"
@pytest.mark.asyncio
async def test_first_request_allowed(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
result = await algo.check(
storage = storage,
key = "bucket_test",
rule = rule,
)
assert result.allowed is True
assert result.remaining == 99
await storage.close()
@pytest.mark.asyncio
async def test_burst_consumption(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 10,
window_seconds = WINDOW_MINUTE
)
for i in range(10):
result = await algo.check(storage, "burst_key", rule)
assert result.allowed is True
assert result.remaining == 10 - (i + 1)
await storage.close()
@pytest.mark.asyncio
async def test_bucket_exhausted(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for _ in range(5):
await algo.check(storage, "exhaust_key", rule)
result = await algo.check(storage, "exhaust_key", rule)
assert result.allowed is False
await storage.close()
@pytest.mark.asyncio
async def test_different_keys_independent(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for _ in range(5):
await algo.check(storage, "bucket_a", rule)
result_a = await algo.check(storage, "bucket_a", rule)
result_b = await algo.check(storage, "bucket_b", rule)
assert result_a.allowed is False
assert result_b.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_empty(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
usage = await algo.get_current_usage(storage, "empty_bucket", rule)
assert usage == 0
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_after_consumption(self) -> None:
algo = TokenBucketAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
for _ in range(25):
await algo.check(storage, "usage_bucket", rule)
usage = await algo.get_current_usage(storage, "usage_bucket", rule)
assert usage >= 24
await storage.close()
class TestFixedWindowAlgorithm:
"""
Tests for fixed window counter algorithm
"""
@pytest.mark.asyncio
async def test_algorithm_name(self) -> None:
algo = FixedWindowAlgorithm()
assert algo.name == "fixed_window"
@pytest.mark.asyncio
async def test_first_request_allowed(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
result = await algo.check(
storage = storage,
key = "fixed_test",
rule = rule,
)
assert result.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_multiple_requests(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
for _ in range(50):
result = await algo.check(storage, "multi_fixed", rule)
assert result.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_limit_exceeded(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for _ in range(5):
result = await algo.check(storage, "limit_fixed", rule)
assert result.allowed is True
result = await algo.check(storage, "limit_fixed", rule)
assert result.allowed is False
await storage.close()
@pytest.mark.asyncio
async def test_different_keys_independent(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for _ in range(5):
await algo.check(storage, "fixed_a", rule)
result_a = await algo.check(storage, "fixed_a", rule)
result_b = await algo.check(storage, "fixed_b", rule)
assert result_a.allowed is False
assert result_b.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_empty(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
usage = await algo.get_current_usage(storage, "empty_fixed", rule)
assert usage == 0
await storage.close()
@pytest.mark.asyncio
async def test_get_current_usage_after_requests(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
for _ in range(25):
await algo.check(storage, "usage_fixed", rule)
usage = await algo.get_current_usage(storage, "usage_fixed", rule)
assert usage >= 0
await storage.close()
@pytest.mark.asyncio
async def test_explicit_timestamp(self) -> None:
algo = FixedWindowAlgorithm()
storage = MemoryStorage()
rule = RuleFactory.per_minute(100)
fixed_time = 1000000.0
result = await algo.check(
storage = storage,
key = "timestamp_fixed",
rule = rule,
timestamp = fixed_time,
)
assert result.allowed is True
await storage.close()
class TestAlgorithmComparison:
"""
Comparative tests between algorithms
"""
@pytest.mark.asyncio
async def test_all_algorithms_allow_first_request(self) -> None:
algorithms = [
SlidingWindowAlgorithm(),
TokenBucketAlgorithm(),
FixedWindowAlgorithm(),
]
rule = RuleFactory.per_minute(100)
for algo in algorithms:
storage = MemoryStorage()
result = await algo.check(storage, "compare_key", rule)
assert result.allowed is True, f"{algo.name} failed first request"
await storage.close()
@pytest.mark.asyncio
async def test_all_algorithms_enforce_limit(self) -> None:
algorithms = [
SlidingWindowAlgorithm(),
TokenBucketAlgorithm(),
FixedWindowAlgorithm(),
]
rule = RuleFactory.create(
requests = 5,
window_seconds = WINDOW_MINUTE
)
for algo in algorithms:
storage = MemoryStorage()
for _ in range(5):
await algo.check(storage, "enforce_key", rule)
result = await algo.check(storage, "enforce_key", rule)
assert result.allowed is False, f"{algo.name} didn't enforce limit"
await storage.close()
@pytest.mark.asyncio
async def test_algorithms_have_correct_names(self) -> None:
assert SlidingWindowAlgorithm(
).name == Algorithm.SLIDING_WINDOW.value
assert TokenBucketAlgorithm().name == Algorithm.TOKEN_BUCKET.value
assert FixedWindowAlgorithm().name == Algorithm.FIXED_WINDOW.value

View File

@ -0,0 +1,536 @@
"""
AngelaMos | 2025
test_fingerprinting.py
"""
from __future__ import annotations
import pytest
from fastapi_420.fingerprinting.ip import IPExtractor
from fastapi_420.fingerprinting.headers import HeadersExtractor
from fastapi_420.fingerprinting.auth import AuthExtractor
from fastapi_420.fingerprinting.composite import CompositeFingerprinter
from fastapi_420.types import FingerprintLevel
from tests.conftest import (
TEST_IP_V4,
TEST_IP_V4_PRIVATE,
TEST_IP_V6,
TEST_IP_V6_NORMALIZED,
TEST_IP_LOCALHOST,
TEST_USER_AGENT,
TEST_ACCEPT_LANGUAGE,
TEST_ACCEPT_ENCODING,
TEST_JWT_TOKEN,
TEST_API_KEY,
TEST_SESSION_ID,
RequestFactory,
)
class TestIPExtractor:
"""
Tests for IP address extraction and normalization
"""
def test_extract_basic_ipv4(self) -> None:
extractor = IPExtractor()
request = RequestFactory.create(client_ip = TEST_IP_V4)
raw_ip, normalized_ip = extractor.extract(request)
assert raw_ip == TEST_IP_V4
assert normalized_ip == TEST_IP_V4
def test_extract_localhost(self) -> None:
extractor = IPExtractor()
request = RequestFactory.create(client_ip = TEST_IP_LOCALHOST)
raw_ip, normalized_ip = extractor.extract(request)
assert raw_ip == TEST_IP_LOCALHOST
assert normalized_ip == TEST_IP_LOCALHOST
def test_extract_private_ip(self) -> None:
extractor = IPExtractor()
request = RequestFactory.create(client_ip = TEST_IP_V4_PRIVATE)
raw_ip, normalized_ip = extractor.extract(request)
assert raw_ip == TEST_IP_V4_PRIVATE
assert normalized_ip == TEST_IP_V4_PRIVATE
def test_ipv6_normalization_to_64_prefix(self) -> None:
extractor = IPExtractor(ipv6_prefix_length = 64)
request = RequestFactory.create(client_ip = TEST_IP_V6)
raw_ip, normalized_ip = extractor.extract(request)
assert raw_ip == TEST_IP_V6
assert normalized_ip == TEST_IP_V6_NORMALIZED
def test_ipv6_custom_prefix_length(self) -> None:
extractor = IPExtractor(ipv6_prefix_length = 48)
request = RequestFactory.create(client_ip = TEST_IP_V6)
raw_ip, normalized_ip = extractor.extract(request)
assert "::" in normalized_ip or normalized_ip != TEST_IP_V6
def test_ipv4_mapped_ipv6(self) -> None:
extractor = IPExtractor()
ipv4_mapped = "::ffff:192.168.1.100"
request = RequestFactory.create(client_ip = ipv4_mapped)
raw_ip, normalized_ip = extractor.extract(request)
assert normalized_ip == "192.168.1.100"
def test_invalid_ip_passthrough(self) -> None:
extractor = IPExtractor()
request = RequestFactory.create(client_ip = "invalid_ip")
raw_ip, normalized_ip = extractor.extract(request)
assert raw_ip == "invalid_ip"
assert normalized_ip == "invalid_ip"
def test_x_forwarded_for_disabled(self) -> None:
extractor = IPExtractor(trust_x_forwarded_for = False)
request = RequestFactory.with_forwarded_for(
forwarded_ips = ["10.0.0.1",
"192.168.1.1"],
client_ip = TEST_IP_V4,
)
raw_ip, _ = extractor.extract(request)
assert raw_ip == TEST_IP_V4
def test_x_forwarded_for_enabled(self) -> None:
extractor = IPExtractor(trust_x_forwarded_for = True)
request = RequestFactory.with_forwarded_for(
forwarded_ips = ["10.0.0.1",
"192.168.1.1"],
client_ip = TEST_IP_V4,
)
raw_ip, _ = extractor.extract(request)
assert raw_ip == "10.0.0.1"
def test_x_forwarded_for_with_trusted_proxies(self) -> None:
extractor = IPExtractor(
trust_x_forwarded_for = True,
trusted_proxies = ["192.168.1.1"],
)
request = RequestFactory.with_forwarded_for(
forwarded_ips = ["10.0.0.1",
"192.168.1.1"],
client_ip = "172.16.0.1",
)
raw_ip, _ = extractor.extract(request)
assert raw_ip == "10.0.0.1"
def test_x_real_ip_header(self) -> None:
extractor = IPExtractor(trusted_proxies = ["127.0.0.1"])
request = RequestFactory.with_forwarded_for(
forwarded_ips = [],
real_ip = "203.0.113.50",
client_ip = "127.0.0.1",
)
raw_ip, _ = extractor.extract(request)
assert raw_ip == "203.0.113.50"
def test_is_ipv6(self) -> None:
extractor = IPExtractor()
assert extractor.is_ipv6(TEST_IP_V6) is True
assert extractor.is_ipv6(TEST_IP_V4) is False
assert extractor.is_ipv6("invalid") is False
def test_is_private(self) -> None:
extractor = IPExtractor()
assert extractor.is_private(TEST_IP_V4_PRIVATE) is True
assert extractor.is_private(TEST_IP_LOCALHOST) is True
assert extractor.is_private("8.8.8.8") is False
assert extractor.is_private("invalid") is False
class TestHeadersExtractor:
"""
Tests for HTTP header extraction and fingerprinting
"""
def test_extract_user_agent(self) -> None:
extractor = HeadersExtractor()
request = RequestFactory.create()
user_agent = extractor.extract_user_agent(request)
assert user_agent == TEST_USER_AGENT
def test_extract_user_agent_missing(self) -> None:
extractor = HeadersExtractor()
request = RequestFactory.create(headers = {"user-agent": ""})
user_agent = extractor.extract_user_agent(request)
assert user_agent == ""
def test_extract_accept_language(self) -> None:
extractor = HeadersExtractor()
request = RequestFactory.create()
accept_lang = extractor.extract_accept_language(request)
assert accept_lang == TEST_ACCEPT_LANGUAGE
def test_extract_accept_encoding(self) -> None:
extractor = HeadersExtractor()
request = RequestFactory.create()
accept_enc = extractor.extract_accept_encoding(request)
assert accept_enc == TEST_ACCEPT_ENCODING
def test_compute_headers_hash(self) -> None:
extractor = HeadersExtractor(hash_length = 16)
request = RequestFactory.create()
headers_hash = extractor.compute_headers_hash(request)
assert len(headers_hash) == 16
assert headers_hash.isalnum()
def test_headers_hash_deterministic(self) -> None:
extractor = HeadersExtractor()
request1 = RequestFactory.create()
request2 = RequestFactory.create()
hash1 = extractor.compute_headers_hash(request1)
hash2 = extractor.compute_headers_hash(request2)
assert hash1 == hash2
def test_headers_hash_different_headers(self) -> None:
extractor = HeadersExtractor()
request1 = RequestFactory.create(
headers = {"user-agent": "Firefox/1.0"}
)
request2 = RequestFactory.create(
headers = {"user-agent": "Chrome/1.0"}
)
hash1 = extractor.compute_headers_hash(request1)
hash2 = extractor.compute_headers_hash(request2)
assert hash1 != hash2
def test_headers_hash_with_order(self) -> None:
extractor = HeadersExtractor(use_header_order = True)
request = RequestFactory.create()
headers_hash = extractor.compute_headers_hash(request)
assert len(headers_hash) == 16
def test_extract_all(self) -> None:
extractor = HeadersExtractor()
request = RequestFactory.create()
data = extractor.extract_all(request)
assert "user_agent" in data
assert "accept_language" in data
assert "accept_encoding" in data
assert "headers_hash" in data
class TestAuthExtractor:
"""
Tests for authentication identifier extraction
"""
def test_extract_jwt_bearer_token(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.with_auth(
auth_type = "bearer",
token = TEST_JWT_TOKEN
)
identifier = extractor.extract(request)
assert identifier == "user_123"
def test_extract_jwt_bearer_token_hashed(self) -> None:
extractor = AuthExtractor(
hash_identifiers = True,
hash_length = 16
)
request = RequestFactory.with_auth(
auth_type = "bearer",
token = TEST_JWT_TOKEN
)
identifier = extractor.extract(request)
assert identifier is not None
assert len(identifier) == 16
assert identifier != "user_123"
def test_extract_api_key_header(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.with_auth(
auth_type = "api_key",
token = TEST_API_KEY
)
identifier = extractor.extract(request)
assert identifier == TEST_API_KEY
def test_extract_api_key_query_param(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.create(
query_params = {"api_key": TEST_API_KEY}
)
identifier = extractor.extract(request)
assert identifier == TEST_API_KEY
def test_extract_session_cookie(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.create(
cookies = {"session_id": TEST_SESSION_ID}
)
identifier = extractor.extract(request)
assert identifier == TEST_SESSION_ID
def test_extract_priority_jwt_over_api_key(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.with_auth(
auth_type = "bearer",
token = TEST_JWT_TOKEN
)
identifier = extractor.extract(request)
assert identifier == "user_123"
def test_extract_no_auth(self) -> None:
extractor = AuthExtractor()
request = RequestFactory.create()
identifier = extractor.extract(request)
assert identifier is None
def test_is_authenticated_true(self) -> None:
extractor = AuthExtractor()
request = RequestFactory.with_auth(
auth_type = "bearer",
token = TEST_JWT_TOKEN
)
assert extractor.is_authenticated(request) is True
def test_is_authenticated_false(self) -> None:
extractor = AuthExtractor()
request = RequestFactory.create()
assert extractor.is_authenticated(request) is False
def test_invalid_jwt_format(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
request = RequestFactory.with_auth(
auth_type = "bearer",
token = "invalid"
)
identifier = extractor.extract(request)
assert identifier is None
def test_jwt_without_sub_claim(self) -> None:
extractor = AuthExtractor(hash_identifiers = False)
token_no_sub = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.signature"
request = RequestFactory.with_auth(
auth_type = "bearer",
token = token_no_sub
)
identifier = extractor.extract(request)
assert identifier == "" or identifier is None
class TestCompositeFingerprinter:
"""
Tests for composite fingerprint extraction
"""
@pytest.mark.asyncio
async def test_normal_level_extraction(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.NORMAL
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
assert fp.ip == TEST_IP_V4
assert fp.user_agent == TEST_USER_AGENT
assert fp.auth_identifier is not None
assert fp.accept_language is None
assert fp.tls_fingerprint is None
@pytest.mark.asyncio
async def test_relaxed_level_extraction(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.RELAXED
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
assert fp.ip == TEST_IP_V4
assert fp.user_agent is None
assert fp.auth_identifier is not None
@pytest.mark.asyncio
async def test_strict_level_extraction(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.STRICT
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
assert fp.ip == TEST_IP_V4
assert fp.user_agent == TEST_USER_AGENT
assert fp.accept_language == TEST_ACCEPT_LANGUAGE
assert fp.accept_encoding == TEST_ACCEPT_ENCODING
assert fp.headers_hash is not None
@pytest.mark.asyncio
async def test_custom_level_ip_only(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.CUSTOM,
use_ip = True,
use_user_agent = False,
use_auth = False,
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
assert fp.ip == TEST_IP_V4
assert fp.user_agent is None
assert fp.auth_identifier is None
@pytest.mark.asyncio
async def test_tls_fingerprint_extraction(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.STRICT
)
request = RequestFactory.create(
headers = {"X-JA3-Fingerprint": "abc123hash"}
)
fp = await fingerprinter.extract(request)
assert fp.tls_fingerprint == "abc123hash"
@pytest.mark.asyncio
async def test_geo_asn_extraction(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.STRICT
)
request = RequestFactory.create(
headers = {"X-Client-ASN": "AS12345"}
)
fp = await fingerprinter.extract(request)
assert fp.geo_asn == "AS12345"
@pytest.mark.asyncio
async def test_cloudflare_country_header(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.STRICT
)
request = RequestFactory.create(headers = {"CF-IPCountry": "US"})
fp = await fingerprinter.extract(request)
assert fp.geo_asn == "US"
def test_is_authenticated_method(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.NORMAL
)
auth_request = RequestFactory.with_auth()
anon_request = RequestFactory.create()
assert fingerprinter.is_authenticated(auth_request) is True
assert fingerprinter.is_authenticated(anon_request) is False
@pytest.mark.asyncio
async def test_from_settings(self) -> None:
from fastapi_420.config import FingerprintSettings
settings = FingerprintSettings(
LEVEL = FingerprintLevel.NORMAL,
USE_IP = True,
USE_USER_AGENT = True,
USE_ACCEPT_HEADERS = False,
USE_AUTH = True,
IPV6_PREFIX_LENGTH = 64,
)
fingerprinter = CompositeFingerprinter.from_settings(settings)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
assert fp.ip is not None
assert fp.user_agent is not None
class TestFingerprintDataCompositeKey:
"""
Tests for composite key generation from fingerprint data
"""
@pytest.mark.asyncio
async def test_composite_key_relaxed_anonymous(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.RELAXED
)
request = RequestFactory.create()
fp = await fingerprinter.extract(request)
key = fp.to_composite_key(FingerprintLevel.RELAXED)
assert key == TEST_IP_V4
@pytest.mark.asyncio
async def test_composite_key_relaxed_authenticated(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.RELAXED
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
key = fp.to_composite_key(FingerprintLevel.RELAXED)
assert TEST_IP_V4 in key
assert ":" in key
@pytest.mark.asyncio
async def test_composite_key_normal(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.NORMAL
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
key = fp.to_composite_key(FingerprintLevel.NORMAL)
parts = key.split(":")
assert len(parts) == 3
assert parts[0] == TEST_IP_V4
assert TEST_USER_AGENT in parts[1]
@pytest.mark.asyncio
async def test_composite_key_strict(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.STRICT
)
request = RequestFactory.with_auth()
fp = await fingerprinter.extract(request)
key = fp.to_composite_key(FingerprintLevel.STRICT)
parts = key.split(":")
assert len(parts) == 8
@pytest.mark.asyncio
async def test_composite_key_deterministic(self) -> None:
fingerprinter = CompositeFingerprinter(
level = FingerprintLevel.NORMAL
)
request = RequestFactory.with_auth()
fp1 = await fingerprinter.extract(request)
fp2 = await fingerprinter.extract(request)
key1 = fp1.to_composite_key(FingerprintLevel.NORMAL)
key2 = fp2.to_composite_key(FingerprintLevel.NORMAL)
assert key1 == key2

View File

@ -0,0 +1,647 @@
"""
AngelaMos | 2025
test_integration.py
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi import Depends, FastAPI, Request
from httpx import ASGITransport, AsyncClient
from fastapi_420.config import FingerprintSettings, RateLimiterSettings
from fastapi_420.dependencies import (
RateLimitDep,
ScopedRateLimiter,
create_rate_limit_dep,
set_global_limiter,
)
from fastapi_420.exceptions import HTTP_420_ENHANCE_YOUR_CALM
from fastapi_420.limiter import RateLimiter
from fastapi_420.middleware import RateLimitMiddleware, SlowDownMiddleware
from fastapi_420.storage import MemoryStorage
from fastapi_420.types import Algorithm
from tests.conftest import (
assert_420_response,
assert_rate_limit_headers,
)
def create_app_with_middleware(
limiter: RateLimiter,
default_limit: str = "100/minute",
exclude_paths: list[str] | None = None,
) -> FastAPI:
"""
Create a FastAPI app with rate limiting middleware
"""
app = FastAPI(title = "Test App")
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/health")
async def health():
return {"status": "healthy"}
@app.get("/api/test")
async def test_endpoint():
return {"endpoint": "test"}
@app.post("/api/test")
async def test_endpoint_post():
return {"created": True}
@app.get("/api/protected")
async def protected():
return {"protected": True}
app.add_middleware(
RateLimitMiddleware,
limiter = limiter,
default_limit = default_limit,
exclude_paths = exclude_paths,
)
return app
def create_app_with_decorator(limiter: RateLimiter) -> FastAPI:
"""
Create a FastAPI app using decorators for rate limiting
"""
app = FastAPI(title = "Test App")
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/api/limited")
@limiter.limit("5/minute")
async def limited_endpoint(request: Request):
return {"limited": True}
@app.get("/api/multi-limited")
@limiter.limit("10/minute", "100/hour")
async def multi_limited_endpoint(request: Request):
return {"multi": True}
@app.get("/api/unlimited")
async def unlimited_endpoint():
return {"unlimited": True}
return app
def create_app_with_dependency(limiter: RateLimiter) -> FastAPI:
"""
Create a FastAPI app using dependency injection for rate limiting
"""
app = FastAPI(title = "Test App")
set_global_limiter(limiter)
rate_limit = create_rate_limit_dep("5/minute")
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/api/limited", dependencies = [Depends(rate_limit)])
async def limited_endpoint():
return {"limited": True}
@app.get("/api/with-result")
async def with_result_endpoint(
result = Depends(RateLimitDep("10/minute"))
):
return {"remaining": result.remaining}
return app
class TestMiddlewareIntegration:
"""
Integration tests for RateLimitMiddleware
"""
@pytest.mark.asyncio
async def test_middleware_allows_requests_under_limit(self) -> None:
storage = MemoryStorage()
settings = RateLimiterSettings(INCLUDE_HEADERS = True)
limiter = RateLimiter(settings = settings, storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "100/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
response = await client.get("/api/test")
assert response.status_code == 200
assert response.json() == {"endpoint": "test"}
assert_rate_limit_headers(
dict(response.headers),
expected_limit = 100
)
await limiter.close()
@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()
@pytest.mark.asyncio
async def test_middleware_excludes_health_endpoint(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "1/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(10):
response = await client.get("/health")
assert response.status_code == 200
await limiter.close()
@pytest.mark.asyncio
async def test_middleware_excludes_custom_paths(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(
limiter,
"1/minute",
exclude_paths = ["/api/test"],
)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(10):
response = await client.get("/api/test")
assert response.status_code == 200
await limiter.close()
@pytest.mark.asyncio
async def test_middleware_different_endpoints_independent(
self
) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "3/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(3):
await client.get("/api/test")
response1 = await client.get("/api/test")
response2 = await client.get("/api/protected")
assert response1.status_code == HTTP_420_ENHANCE_YOUR_CALM
assert response2.status_code == 200
await limiter.close()
@pytest.mark.asyncio
async def test_middleware_post_and_get_independent_limits(
self
) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "3/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(3):
await client.get("/api/test")
get_response = await client.get("/api/test")
assert get_response.status_code == HTTP_420_ENHANCE_YOUR_CALM
post_response = await client.post("/api/test")
assert post_response.status_code == 200
await limiter.close()
class TestDecoratorIntegration:
"""
Integration tests for @limiter.limit() decorator
"""
@pytest.mark.asyncio
async def test_decorator_allows_requests_under_limit(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_decorator(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
response = await client.get("/api/limited")
assert response.status_code == 200
assert response.json() == {"limited": True}
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_returns_420_when_exceeded(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_decorator(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(5):
response = await client.get("/api/limited")
assert response.status_code == 200
response = await client.get("/api/limited")
assert_420_response(response)
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_unlimited_endpoint_not_affected(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_decorator(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(100):
response = await client.get("/api/unlimited")
assert response.status_code == 200
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_different_endpoints_independent(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_decorator(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(5):
await client.get("/api/limited")
response1 = await client.get("/api/limited")
response2 = await client.get("/api/multi-limited")
assert response1.status_code == HTTP_420_ENHANCE_YOUR_CALM
assert response2.status_code == 200
await limiter.close()
class TestDependencyIntegration:
"""
Integration tests for dependency injection
"""
@pytest.mark.asyncio
async def test_dependency_allows_requests_under_limit(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_dependency(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
response = await client.get("/api/limited")
assert response.status_code == 200
assert response.json() == {"limited": True}
await limiter.close()
@pytest.mark.asyncio
async def test_dependency_returns_420_when_exceeded(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_dependency(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(5):
response = await client.get("/api/limited")
assert response.status_code == 200
response = await client.get("/api/limited")
assert_420_response(response)
await limiter.close()
@pytest.mark.asyncio
async def test_dependency_with_result_access(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_dependency(limiter)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
response = await client.get("/api/with-result")
assert response.status_code == 200
data = response.json()
assert "remaining" in data
assert data["remaining"] == 9
await limiter.close()
class TestScopedRateLimiterIntegration:
"""
Integration tests for ScopedRateLimiter
"""
@pytest.mark.asyncio
async def test_scoped_limiter_applies_default_rules(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
set_global_limiter(limiter)
app = FastAPI()
scoped = ScopedRateLimiter(
prefix = "/api",
default_rules = ["5/minute"],
)
@app.get("/api/endpoint", dependencies = [Depends(scoped)])
async def endpoint():
return {"ok": True}
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(5):
response = await client.get("/api/endpoint")
assert response.status_code == 200
response = await client.get("/api/endpoint")
assert_420_response(response)
await limiter.close()
@pytest.mark.asyncio
async def test_scoped_limiter_endpoint_specific_rules(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
set_global_limiter(limiter)
app = FastAPI()
scoped = ScopedRateLimiter(
prefix = "/api",
default_rules = ["100/minute"],
endpoint_rules = {
"GET:/api/strict": ["2/minute"],
},
)
@app.get("/api/normal", dependencies = [Depends(scoped)])
async def normal():
return {"type": "normal"}
@app.get("/api/strict", dependencies = [Depends(scoped)])
async def strict():
return {"type": "strict"}
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
for _ in range(2):
response = await client.get("/api/strict")
assert response.status_code == 200
response = await client.get("/api/strict")
assert_420_response(response)
response = await client.get("/api/normal")
assert response.status_code == 200
await limiter.close()
class TestSlowDownMiddlewareIntegration:
"""
Integration tests for SlowDownMiddleware
"""
@pytest.mark.asyncio
async def test_slowdown_middleware_allows_requests(self) -> None:
storage = MemoryStorage()
settings = RateLimiterSettings(INCLUDE_HEADERS = True)
limiter = RateLimiter(settings = settings, storage = storage)
await limiter.init()
app = FastAPI()
@app.get("/api/test")
async def test_endpoint():
return {"ok": True}
app.add_middleware(
SlowDownMiddleware,
limiter = limiter,
threshold_limit = "100/minute",
max_delay_seconds = 1.0,
)
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
response = await client.get("/api/test")
assert response.status_code == 200
assert response.json() == {"ok": True}
await limiter.close()
class TestConcurrentRequests:
"""
Integration tests for concurrent request handling
"""
@pytest.mark.asyncio
async def test_concurrent_requests_all_counted(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "100/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
tasks = [client.get("/api/test") for _ in range(50)]
responses = await asyncio.gather(*tasks)
success_count = sum(
1 for r in responses if r.status_code == 200
)
assert success_count == 50
await limiter.close()
@pytest.mark.asyncio
async def test_concurrent_requests_enforce_limit(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
app = create_app_with_middleware(limiter, "10/minute")
async with AsyncClient(transport = ASGITransport(app = app),
base_url = "http://test") as client:
tasks = [client.get("/api/test") for _ in range(20)]
responses = await asyncio.gather(*tasks)
success_count = sum(
1 for r in responses if r.status_code == 200
)
blocked_count = sum(
1 for r in responses
if r.status_code == HTTP_420_ENHANCE_YOUR_CALM
)
assert success_count == 10
assert blocked_count == 10
await limiter.close()
class TestMultipleClients:
"""
Integration tests for multiple clients (different IPs)
"""
@pytest.mark.asyncio
async def test_different_ips_independent_limits(self) -> None:
storage = MemoryStorage()
fingerprint_settings = FingerprintSettings(
TRUST_X_FORWARDED_FOR = True
)
settings = RateLimiterSettings(fingerprint = fingerprint_settings)
limiter = RateLimiter(settings = settings, 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):
await client.get(
"/api/test",
headers = {"X-Forwarded-For": "192.168.1.1"}
)
response1 = await client.get(
"/api/test",
headers = {"X-Forwarded-For": "192.168.1.1"}
)
response2 = await client.get(
"/api/test",
headers = {"X-Forwarded-For": "192.168.1.2"}
)
assert response1.status_code == HTTP_420_ENHANCE_YOUR_CALM
assert response2.status_code == 200
await limiter.close()
class TestAlgorithmIntegration:
"""
Integration tests for different algorithms
"""
@pytest.mark.asyncio
async def test_sliding_window_algorithm(self) -> None:
settings = RateLimiterSettings(
ALGORITHM = Algorithm.SLIDING_WINDOW
)
storage = MemoryStorage()
limiter = RateLimiter(settings = settings, 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()
@pytest.mark.asyncio
async def test_token_bucket_algorithm(self) -> None:
settings = RateLimiterSettings(ALGORITHM = Algorithm.TOKEN_BUCKET)
storage = MemoryStorage()
limiter = RateLimiter(settings = settings, 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()
@pytest.mark.asyncio
async def test_fixed_window_algorithm(self) -> None:
settings = RateLimiterSettings(ALGORITHM = Algorithm.FIXED_WINDOW)
storage = MemoryStorage()
limiter = RateLimiter(settings = settings, 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()

View File

@ -0,0 +1,443 @@
"""
AngelaMos | 2025
test_limiter.py
"""
from __future__ import annotations
import pytest
from fastapi_420.config import RateLimiterSettings
from fastapi_420.exceptions import EnhanceYourCalm, HTTP_420_ENHANCE_YOUR_CALM
from fastapi_420.limiter import RateLimiter
from fastapi_420.storage import MemoryStorage
from fastapi_420.types import Algorithm, FingerprintLevel
from tests.conftest import (
WINDOW_MINUTE,
DEFAULT_LIMIT_REQUESTS,
RequestFactory,
)
class TestRateLimiterInit:
"""
Tests for RateLimiter initialization
"""
@pytest.mark.asyncio
async def test_init_with_defaults(self) -> None:
limiter = RateLimiter()
assert limiter.is_initialized is False
await limiter.init()
assert limiter.is_initialized is True
await limiter.close()
@pytest.mark.asyncio
async def test_init_with_custom_settings(self) -> None:
settings = RateLimiterSettings(
ALGORITHM = Algorithm.TOKEN_BUCKET,
DEFAULT_LIMIT = "50/minute",
)
limiter = RateLimiter(settings = settings)
await limiter.init()
assert limiter.settings.ALGORITHM == Algorithm.TOKEN_BUCKET
assert limiter.settings.DEFAULT_LIMIT == "50/minute"
await limiter.close()
@pytest.mark.asyncio
async def test_init_with_provided_storage(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
assert limiter.is_initialized is True
await limiter.close()
@pytest.mark.asyncio
async def test_init_idempotent(self) -> None:
limiter = RateLimiter()
await limiter.init()
await limiter.init()
await limiter.init()
assert limiter.is_initialized is True
await limiter.close()
@pytest.mark.asyncio
async def test_close_resets_initialized(self) -> None:
limiter = RateLimiter()
await limiter.init()
assert limiter.is_initialized is True
await limiter.close()
assert limiter.is_initialized is False
class TestRateLimiterCheck:
"""
Tests for RateLimiter.check() method
"""
@pytest.mark.asyncio
async def test_check_allows_first_request(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request = RequestFactory.create()
result = await limiter.check(
request,
"100/minute",
raise_on_limit = False
)
assert result.allowed is True
assert result.remaining == 99
await limiter.close()
@pytest.mark.asyncio
async def test_check_multiple_rules(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request = RequestFactory.create()
result = await limiter.check(
request,
"100/minute",
"1000/hour",
raise_on_limit = False,
)
assert result.allowed is True
await limiter.close()
@pytest.mark.asyncio
async def test_check_uses_default_rules_when_none_provided(
self
) -> None:
settings = RateLimiterSettings(
DEFAULT_LIMITS = ["50/minute"],
)
storage = MemoryStorage()
limiter = RateLimiter(settings = settings, storage = storage)
await limiter.init()
request = RequestFactory.create()
result = await limiter.check(request, raise_on_limit = False)
assert result.allowed is True
assert result.limit == 50
await limiter.close()
@pytest.mark.asyncio
async def test_check_raises_on_limit(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request = RequestFactory.create()
for _ in range(5):
await limiter.check(
request,
"5/minute",
raise_on_limit = False
)
with pytest.raises(EnhanceYourCalm) as exc_info:
await limiter.check(request, "5/minute", raise_on_limit = True)
assert exc_info.value.status_code == HTTP_420_ENHANCE_YOUR_CALM
await limiter.close()
@pytest.mark.asyncio
async def test_check_returns_result_when_not_raising(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request = RequestFactory.create()
for _ in range(5):
await limiter.check(
request,
"5/minute",
raise_on_limit = False
)
result = await limiter.check(
request,
"5/minute",
raise_on_limit = False
)
assert result.allowed is False
assert result.remaining == 0
await limiter.close()
@pytest.mark.asyncio
async def test_check_different_endpoints_independent(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request1 = RequestFactory.create(path = "/api/endpoint1")
request2 = RequestFactory.create(path = "/api/endpoint2")
for _ in range(5):
await limiter.check(
request1,
"5/minute",
raise_on_limit = False
)
result1 = await limiter.check(
request1,
"5/minute",
raise_on_limit = False
)
result2 = await limiter.check(
request2,
"5/minute",
raise_on_limit = False
)
assert result1.allowed is False
assert result2.allowed is True
await limiter.close()
@pytest.mark.asyncio
async def test_check_different_users_independent(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
request1 = RequestFactory.create(client_ip = "192.168.1.1")
request2 = RequestFactory.create(client_ip = "192.168.1.2")
for _ in range(5):
await limiter.check(
request1,
"5/minute",
raise_on_limit = False
)
result1 = await limiter.check(
request1,
"5/minute",
raise_on_limit = False
)
result2 = await limiter.check(
request2,
"5/minute",
raise_on_limit = False
)
assert result1.allowed is False
assert result2.allowed is True
await limiter.close()
@pytest.mark.asyncio
async def test_check_with_custom_key_func(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
def custom_key(request):
return "shared_key"
request1 = RequestFactory.create(client_ip = "192.168.1.1")
request2 = RequestFactory.create(client_ip = "192.168.1.2")
for _ in range(5):
await limiter.check(
request1,
"5/minute",
key_func = custom_key,
raise_on_limit = False,
)
result = await limiter.check(
request2,
"5/minute",
key_func = custom_key,
raise_on_limit = False,
)
assert result.allowed is False
await limiter.close()
@pytest.mark.asyncio
async def test_check_auto_initializes(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
assert limiter.is_initialized is False
request = RequestFactory.create()
result = await limiter.check(
request,
"100/minute",
raise_on_limit = False
)
assert limiter.is_initialized is True
assert result.allowed is True
await limiter.close()
class TestRateLimiterDecorator:
"""
Tests for RateLimiter.limit() decorator
"""
@pytest.mark.asyncio
async def test_decorator_basic_usage(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
@limiter.limit("100/minute")
async def endpoint(request):
return {"success": True}
request = RequestFactory.create()
result = await endpoint(request)
assert result == {"success": True}
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_enforces_limit(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
@limiter.limit("5/minute")
async def endpoint(request):
return {"success": True}
request = RequestFactory.create()
for _ in range(5):
await endpoint(request)
with pytest.raises(EnhanceYourCalm):
await endpoint(request)
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_multiple_rules(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
@limiter.limit("100/minute", "1000/hour")
async def endpoint(request):
return {"success": True}
request = RequestFactory.create()
result = await endpoint(request)
assert result == {"success": True}
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_without_request_skips(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
@limiter.limit("5/minute")
async def endpoint():
return {"success": True}
result = await endpoint()
assert result == {"success": True}
await limiter.close()
@pytest.mark.asyncio
async def test_decorator_request_in_kwargs(self) -> None:
storage = MemoryStorage()
limiter = RateLimiter(storage = storage)
await limiter.init()
@limiter.limit("100/minute")
async def endpoint(data: str, request = None):
return {"data": data}
request = RequestFactory.create()
result = await endpoint("test", request = request)
assert result == {"data": "test"}
await limiter.close()
class TestRateLimiterFailOpen:
"""
Tests for fail-open behavior
"""
@pytest.mark.asyncio
async def test_fail_open_allows_requests_on_storage_failure(
self
) -> None:
settings = RateLimiterSettings(FAIL_OPEN = True)
storage = MemoryStorage()
await storage.close()
limiter = RateLimiter(settings = settings, storage = storage)
limiter._initialized = True
limiter._storage = storage
limiter._fallback_storage = None
request = RequestFactory.create()
result = await limiter.check(
request,
"100/minute",
raise_on_limit = False
)
assert result.allowed is True
class TestRateLimiterSettings:
"""
Tests for settings access
"""
@pytest.mark.asyncio
async def test_settings_property(self) -> None:
settings = RateLimiterSettings(
HTTP_420_MESSAGE = "Custom message",
HTTP_420_DETAIL = "Custom detail",
)
limiter = RateLimiter(settings = settings)
assert limiter.settings.HTTP_420_MESSAGE == "Custom message"
assert limiter.settings.HTTP_420_DETAIL == "Custom detail"
@pytest.mark.asyncio
async def test_is_initialized_property(self) -> None:
limiter = RateLimiter()
assert limiter.is_initialized is False
await limiter.init()
assert limiter.is_initialized is True
await limiter.close()
assert limiter.is_initialized is False

View File

@ -0,0 +1,428 @@
"""
AngelaMos | 2025
test_storage.py
"""
from __future__ import annotations
import asyncio
import time
import pytest
from fastapi_420.storage import MemoryStorage, create_storage
from fastapi_420.config import StorageSettings
from fastapi_420.types import StorageType
from tests.conftest import (
WINDOW_MINUTE,
WINDOW_SECOND,
DEFAULT_LIMIT_REQUESTS,
)
class TestMemoryStorageBasic:
"""
Basic MemoryStorage creation and lifecycle tests
"""
@pytest.mark.asyncio
async def test_create_storage(self) -> None:
storage = MemoryStorage()
assert storage.storage_type == StorageType.MEMORY
assert storage.max_keys == 100_000
@pytest.mark.asyncio
async def test_create_storage_custom_settings(self) -> None:
storage = MemoryStorage(max_keys = 5000, cleanup_interval = 30)
assert storage.max_keys == 5000
assert storage.cleanup_interval == 30
@pytest.mark.asyncio
async def test_from_settings(self) -> None:
settings = StorageSettings(
MEMORY_MAX_KEYS = 2000,
MEMORY_CLEANUP_INTERVAL = 120,
)
storage = MemoryStorage.from_settings(settings)
assert storage.max_keys == 2000
assert storage.cleanup_interval == 120
@pytest.mark.asyncio
async def test_health_check_healthy(self) -> None:
storage = MemoryStorage()
assert await storage.health_check() is True
@pytest.mark.asyncio
async def test_health_check_after_close(self) -> None:
storage = MemoryStorage()
await storage.close()
assert await storage.health_check() is False
@pytest.mark.asyncio
async def test_close_clears_data(self) -> None:
storage = MemoryStorage()
await storage.increment("test_key", WINDOW_MINUTE, 100)
await storage.close()
assert len(storage._windows) == 0
assert len(storage._buckets) == 0
class TestMemoryStorageSlidingWindow:
"""
Tests for sliding window counter in MemoryStorage
"""
@pytest.mark.asyncio
async def test_increment_first_request(self) -> None:
storage = MemoryStorage()
result = await storage.increment(
key = "test",
window_seconds = WINDOW_MINUTE,
limit = 100,
)
assert result.allowed is True
assert result.limit == 100
assert result.remaining == 99
await storage.close()
@pytest.mark.asyncio
async def test_increment_multiple_requests(self) -> None:
storage = MemoryStorage()
key = "multi_test"
for i in range(10):
result = await storage.increment(key, WINDOW_MINUTE, 100)
assert result.allowed is True
assert result.remaining == 100 - (i + 1)
await storage.close()
@pytest.mark.asyncio
async def test_increment_reaches_limit(self) -> None:
storage = MemoryStorage()
key = "limit_test"
limit = 5
for _ in range(limit):
result = await storage.increment(key, WINDOW_MINUTE, limit)
assert result.allowed is True
result = await storage.increment(key, WINDOW_MINUTE, limit)
assert result.allowed is False
assert result.remaining == 0
assert result.retry_after is not None
assert result.retry_after > 0
await storage.close()
@pytest.mark.asyncio
async def test_increment_with_explicit_timestamp(self) -> None:
storage = MemoryStorage()
fixed_time = 1000000.0
result = await storage.increment(
key = "timestamp_test",
window_seconds = WINDOW_MINUTE,
limit = 100,
timestamp = fixed_time,
)
assert result.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_get_window_state_empty(self) -> None:
storage = MemoryStorage()
state = await storage.get_window_state(
"nonexistent",
WINDOW_MINUTE
)
assert state.current_count == 0
assert state.previous_count == 0
await storage.close()
@pytest.mark.asyncio
async def test_get_window_state_with_data(self) -> None:
storage = MemoryStorage()
key = "state_test"
for _ in range(5):
await storage.increment(key, WINDOW_MINUTE, 100)
state = await storage.get_window_state(key, WINDOW_MINUTE)
assert state.current_count == 5
await storage.close()
@pytest.mark.asyncio
async def test_sliding_window_weighted_count(self) -> None:
storage = MemoryStorage()
key = "weighted_test"
window = 2
limit = 100
base_time = 1000.0
current_window = int(base_time // window)
previous_window = current_window - 1
prev_key = f"{key}:{previous_window}"
curr_key = f"{key}:{current_window}"
storage._windows[prev_key] = storage._windows.__class__().__class__
from fastapi_420.storage.memory import WindowEntry
storage._windows[prev_key] = WindowEntry(
count = 50,
window_start = previous_window,
expires_at = base_time + window * 2,
)
result = await storage.increment(
key = key,
window_seconds = window,
limit = limit,
timestamp = base_time + 1.0,
)
assert result.allowed is True
await storage.close()
class TestMemoryStorageTokenBucket:
"""
Tests for token bucket algorithm in MemoryStorage
"""
@pytest.mark.asyncio
async def test_consume_token_first_request(self) -> None:
storage = MemoryStorage()
result = await storage.consume_token(
key = "bucket_test",
capacity = 100,
refill_rate = 1.67,
tokens_to_consume = 1,
)
assert result.allowed is True
assert result.remaining == 99
await storage.close()
@pytest.mark.asyncio
async def test_consume_token_multiple(self) -> None:
storage = MemoryStorage()
key = "multi_bucket"
for i in range(10):
result = await storage.consume_token(
key = key,
capacity = 100,
refill_rate = 1.67,
)
assert result.allowed is True
assert result.remaining == 100 - (i + 1)
await storage.close()
@pytest.mark.asyncio
async def test_consume_token_exhausted(self) -> None:
storage = MemoryStorage()
key = "exhaust_bucket"
capacity = 5
for _ in range(capacity):
result = await storage.consume_token(
key = key,
capacity = capacity,
refill_rate = 1.0,
)
assert result.allowed is True
result = await storage.consume_token(
key = key,
capacity = capacity,
refill_rate = 1.0,
)
assert result.allowed is False
assert result.retry_after is not None
await storage.close()
@pytest.mark.asyncio
async def test_consume_token_refill(self) -> None:
storage = MemoryStorage()
key = "refill_test"
capacity = 10
refill_rate = 10.0
for _ in range(capacity):
await storage.consume_token(key, capacity, refill_rate)
result = await storage.consume_token(key, capacity, refill_rate)
assert result.allowed is False
await asyncio.sleep(0.15)
result = await storage.consume_token(key, capacity, refill_rate)
assert result.allowed is True
await storage.close()
@pytest.mark.asyncio
async def test_get_token_bucket_state_empty(self) -> None:
storage = MemoryStorage()
state = await storage.get_token_bucket_state("nonexistent")
assert state is None
await storage.close()
@pytest.mark.asyncio
async def test_get_token_bucket_state_exists(self) -> None:
storage = MemoryStorage()
key = "bucket_state_test"
await storage.consume_token(
key,
capacity = 100,
refill_rate = 1.67
)
state = await storage.get_token_bucket_state(key)
assert state is not None
assert state.tokens == 99.0
assert state.capacity == 100
await storage.close()
class TestMemoryStorageMaxKeys:
"""
Tests for key eviction when max_keys is exceeded
"""
@pytest.mark.asyncio
async def test_max_keys_eviction(self) -> None:
storage = MemoryStorage(max_keys = 5)
for i in range(10):
await storage.increment(f"key_{i}", WINDOW_MINUTE, 100)
assert len(storage._windows) <= 6
await storage.close()
@pytest.mark.asyncio
async def test_lru_eviction_order(self) -> None:
storage = MemoryStorage(max_keys = 3)
await storage.increment("key_a", WINDOW_MINUTE, 100)
await storage.increment("key_b", WINDOW_MINUTE, 100)
await storage.increment("key_c", WINDOW_MINUTE, 100)
await storage.increment("key_d", WINDOW_MINUTE, 100)
keys = list(storage._windows.keys())
assert not any("key_a" in k for k in keys)
await storage.close()
class TestMemoryStorageCleanup:
"""
Tests for automatic cleanup of expired entries
"""
@pytest.mark.asyncio
async def test_cleanup_expired_entries(self) -> None:
storage = MemoryStorage(cleanup_interval = 60)
from fastapi_420.storage.memory import WindowEntry
storage._windows["expired_key"] = WindowEntry(
count = 10,
window_start = 1,
expires_at = time.time() - 100,
)
storage._windows["valid_key"] = WindowEntry(
count = 10,
window_start = 1,
expires_at = time.time() + 100,
)
await storage._cleanup_expired()
assert "expired_key" not in storage._windows
assert "valid_key" in storage._windows
await storage.close()
@pytest.mark.asyncio
async def test_cleanup_task_starts(self) -> None:
storage = MemoryStorage(cleanup_interval = 1)
await storage.start_cleanup_task()
assert storage._cleanup_task is not None
assert not storage._cleanup_task.done()
await storage.close()
@pytest.mark.asyncio
async def test_cleanup_task_stops_on_close(self) -> None:
storage = MemoryStorage(cleanup_interval = 1)
await storage.start_cleanup_task()
task = storage._cleanup_task
await storage.close()
assert storage._cleanup_task is None
class TestStorageFactory:
"""
Tests for create_storage factory function
"""
def test_create_memory_storage_no_redis(self) -> None:
settings = StorageSettings(REDIS_URL = None)
storage = create_storage(settings)
assert isinstance(storage, MemoryStorage)
def test_create_memory_storage_explicit(self) -> None:
settings = StorageSettings(
REDIS_URL = None,
MEMORY_MAX_KEYS = 5000,
)
storage = create_storage(settings)
assert isinstance(storage, MemoryStorage)
assert storage.max_keys == 5000
class TestMemoryStorageConcurrency:
"""
Tests for concurrent access to MemoryStorage
"""
@pytest.mark.asyncio
async def test_concurrent_increments(self) -> None:
storage = MemoryStorage()
key = "concurrent_test"
limit = 1000
async def increment() -> bool:
result = await storage.increment(key, WINDOW_MINUTE, limit)
return result.allowed
tasks = [increment() for _ in range(100)]
results = await asyncio.gather(*tasks)
assert all(results)
state = await storage.get_window_state(key, WINDOW_MINUTE)
assert state.current_count == 100
await storage.close()
@pytest.mark.asyncio
async def test_concurrent_different_keys(self) -> None:
storage = MemoryStorage()
limit = 100
async def increment(key: str) -> int:
for _ in range(10):
await storage.increment(key, WINDOW_MINUTE, limit)
state = await storage.get_window_state(key, WINDOW_MINUTE)
return state.current_count
tasks = [increment(f"key_{i}") for i in range(10)]
results = await asyncio.gather(*tasks)
assert all(r == 10 for r in results)
await storage.close()

View File

@ -0,0 +1,553 @@
"""
AngelaMos | 2025
test_types.py
"""
from __future__ import annotations
import pytest
from fastapi_420.types import (
Algorithm,
CircuitState,
DefenseContext,
DefenseMode,
FingerprintData,
FingerprintLevel,
Layer,
RateLimitKey,
RateLimitResult,
RateLimitRule,
StorageType,
TokenBucketState,
WindowState,
)
from tests.conftest import (
KEY_PREFIX,
KEY_VERSION,
TEST_ENDPOINT,
TEST_IP_V4,
TEST_USER_AGENT,
WINDOW_DAY,
WINDOW_HOUR,
WINDOW_MINUTE,
WINDOW_SECOND,
CircuitStateFactory,
DefenseContextFactory,
FingerprintFactory,
KeyFactory,
ResultFactory,
RuleFactory,
TokenBucketStateFactory,
WindowStateFactory,
)
class TestAlgorithmEnum:
"""
Tests for Algorithm enum values
"""
def test_sliding_window_value(self) -> None:
assert Algorithm.SLIDING_WINDOW.value == "sliding_window"
def test_token_bucket_value(self) -> None:
assert Algorithm.TOKEN_BUCKET.value == "token_bucket"
def test_fixed_window_value(self) -> None:
assert Algorithm.FIXED_WINDOW.value == "fixed_window"
def test_leaky_bucket_value(self) -> None:
assert Algorithm.LEAKY_BUCKET.value == "leaky_bucket"
def test_enum_is_str_enum(self) -> None:
assert isinstance(Algorithm.SLIDING_WINDOW, str)
assert Algorithm.SLIDING_WINDOW == "sliding_window"
class TestFingerprintLevelEnum:
"""
Tests for FingerprintLevel enum values
"""
def test_strict_value(self) -> None:
assert FingerprintLevel.STRICT.value == "strict"
def test_normal_value(self) -> None:
assert FingerprintLevel.NORMAL.value == "normal"
def test_relaxed_value(self) -> None:
assert FingerprintLevel.RELAXED.value == "relaxed"
def test_custom_value(self) -> None:
assert FingerprintLevel.CUSTOM.value == "custom"
class TestDefenseModeEnum:
"""
Tests for DefenseMode enum values
"""
def test_adaptive_value(self) -> None:
assert DefenseMode.ADAPTIVE.value == "adaptive"
def test_lockdown_value(self) -> None:
assert DefenseMode.LOCKDOWN.value == "lockdown"
def test_challenge_value(self) -> None:
assert DefenseMode.CHALLENGE.value == "challenge"
def test_disabled_value(self) -> None:
assert DefenseMode.DISABLED.value == "disabled"
class TestStorageTypeEnum:
"""
Tests for StorageType enum values
"""
def test_redis_value(self) -> None:
assert StorageType.REDIS.value == "redis"
def test_memory_value(self) -> None:
assert StorageType.MEMORY.value == "memory"
class TestLayerEnum:
"""
Tests for Layer enum values
"""
def test_user_value(self) -> None:
assert Layer.USER.value == "user"
def test_endpoint_value(self) -> None:
assert Layer.ENDPOINT.value == "endpoint"
def test_global_value(self) -> None:
assert Layer.GLOBAL.value == "global"
class TestRateLimitRule:
"""
Tests for RateLimitRule dataclass and parsing
"""
def test_create_valid_rule(self) -> None:
rule = RateLimitRule(requests = 100, window_seconds = 60)
assert rule.requests == 100
assert rule.window_seconds == 60
def test_invalid_requests_zero(self) -> None:
with pytest.raises(ValueError,
match = "requests must be positive"):
RateLimitRule(requests = 0, window_seconds = 60)
def test_invalid_requests_negative(self) -> None:
with pytest.raises(ValueError,
match = "requests must be positive"):
RateLimitRule(requests = -1, window_seconds = 60)
def test_invalid_window_zero(self) -> None:
with pytest.raises(ValueError,
match = "window_seconds must be positive"):
RateLimitRule(requests = 100, window_seconds = 0)
def test_invalid_window_negative(self) -> None:
with pytest.raises(ValueError,
match = "window_seconds must be positive"):
RateLimitRule(requests = 100, window_seconds = -1)
def test_parse_per_second(self) -> None:
for unit in ["second", "seconds", "sec", "s"]:
rule = RateLimitRule.parse(f"10/{unit}")
assert rule.requests == 10
assert rule.window_seconds == WINDOW_SECOND
def test_parse_per_minute(self) -> None:
for unit in ["minute", "minutes", "min", "m"]:
rule = RateLimitRule.parse(f"100/{unit}")
assert rule.requests == 100
assert rule.window_seconds == WINDOW_MINUTE
def test_parse_per_hour(self) -> None:
for unit in ["hour", "hours", "hr", "h"]:
rule = RateLimitRule.parse(f"1000/{unit}")
assert rule.requests == 1000
assert rule.window_seconds == WINDOW_HOUR
def test_parse_per_day(self) -> None:
for unit in ["day", "days", "d"]:
rule = RateLimitRule.parse(f"10000/{unit}")
assert rule.requests == 10000
assert rule.window_seconds == WINDOW_DAY
def test_parse_with_whitespace(self) -> None:
rule = RateLimitRule.parse(" 100 / minute ")
assert rule.requests == 100
assert rule.window_seconds == WINDOW_MINUTE
def test_parse_case_insensitive(self) -> None:
rule = RateLimitRule.parse("100/MINUTE")
assert rule.requests == 100
assert rule.window_seconds == WINDOW_MINUTE
def test_parse_invalid_format_no_slash(self) -> None:
with pytest.raises(ValueError,
match = "Invalid rate limit format"):
RateLimitRule.parse("100minute")
def test_parse_invalid_format_multiple_slashes(self) -> None:
with pytest.raises(ValueError,
match = "Invalid rate limit format"):
RateLimitRule.parse("100/per/minute")
def test_parse_invalid_request_count(self) -> None:
with pytest.raises(ValueError, match = "Invalid request count"):
RateLimitRule.parse("abc/minute")
def test_parse_unknown_time_unit(self) -> None:
with pytest.raises(ValueError, match = "Unknown time unit"):
RateLimitRule.parse("100/fortnight")
def test_str_representation_second(self) -> None:
rule = RateLimitRule(requests = 10, window_seconds = 1)
assert str(rule) == "10/second"
def test_str_representation_minute(self) -> None:
rule = RateLimitRule(requests = 100, window_seconds = 60)
assert str(rule) == "100/minute"
def test_str_representation_hour(self) -> None:
rule = RateLimitRule(requests = 1000, window_seconds = 3600)
assert str(rule) == "1000/hour"
def test_str_representation_day(self) -> None:
rule = RateLimitRule(requests = 10000, window_seconds = 86400)
assert str(rule) == "10000/day"
def test_str_representation_custom_window(self) -> None:
rule = RateLimitRule(requests = 50, window_seconds = 120)
assert str(rule) == "50/120s"
def test_frozen_immutable(self) -> None:
rule = RateLimitRule(requests = 100, window_seconds = 60)
with pytest.raises(AttributeError):
rule.requests = 200
def test_factory_create(self) -> None:
rule = RuleFactory.create()
assert rule.requests == 100
assert rule.window_seconds == WINDOW_MINUTE
def test_factory_per_second(self) -> None:
rule = RuleFactory.per_second(10)
assert rule.requests == 10
assert rule.window_seconds == WINDOW_SECOND
def test_factory_strict(self) -> None:
rule = RuleFactory.strict()
assert rule.requests == 10
assert rule.window_seconds == WINDOW_MINUTE
class TestRateLimitResult:
"""
Tests for RateLimitResult dataclass and headers
"""
def test_allowed_result(self) -> None:
result = RateLimitResult(
allowed = True,
limit = 100,
remaining = 50,
reset_after = 30.0,
)
assert result.allowed is True
assert result.limit == 100
assert result.remaining == 50
assert result.reset_after == 30.0
assert result.retry_after is None
def test_denied_result(self) -> None:
result = RateLimitResult(
allowed = False,
limit = 100,
remaining = 0,
reset_after = 60.0,
retry_after = 60.0,
)
assert result.allowed is False
assert result.remaining == 0
assert result.retry_after == 60.0
def test_headers_basic(self) -> None:
result = RateLimitResult(
allowed = True,
limit = 100,
remaining = 50,
reset_after = 30.5,
)
headers = result.headers
assert headers["RateLimit-Limit"] == "100"
assert headers["RateLimit-Remaining"] == "50"
assert headers["RateLimit-Reset"] == "30"
assert "Retry-After" not in headers
def test_headers_with_retry_after(self) -> None:
result = RateLimitResult(
allowed = False,
limit = 100,
remaining = 0,
reset_after = 60.0,
retry_after = 45.5,
)
headers = result.headers
assert headers["Retry-After"] == "45"
def test_headers_remaining_never_negative(self) -> None:
result = RateLimitResult(
allowed = False,
limit = 100,
remaining = -5,
reset_after = 60.0,
)
headers = result.headers
assert headers["RateLimit-Remaining"] == "0"
def test_frozen_immutable(self) -> None:
result = ResultFactory.allowed()
with pytest.raises(AttributeError):
result.allowed = False
def test_factory_allowed(self) -> None:
result = ResultFactory.allowed(limit = 200, remaining = 150)
assert result.allowed is True
assert result.limit == 200
assert result.remaining == 150
def test_factory_denied(self) -> None:
result = ResultFactory.denied(retry_after = 30.0)
assert result.allowed is False
assert result.remaining == 0
assert result.retry_after == 30.0
def test_factory_near_limit(self) -> None:
result = ResultFactory.near_limit(remaining = 2)
assert result.allowed is True
assert result.remaining == 2
class TestFingerprintData:
"""
Tests for FingerprintData and composite key generation
"""
def test_create_full_fingerprint(self) -> None:
fp = FingerprintData(
ip = TEST_IP_V4,
ip_normalized = TEST_IP_V4,
user_agent = TEST_USER_AGENT,
accept_language = "en-US",
accept_encoding = "gzip",
headers_hash = "abc123",
auth_identifier = "user_456",
tls_fingerprint = "ja3hash",
geo_asn = "AS12345",
)
assert fp.ip == TEST_IP_V4
assert fp.user_agent == TEST_USER_AGENT
assert fp.auth_identifier == "user_456"
def test_create_minimal_fingerprint(self) -> None:
fp = FingerprintData(ip = TEST_IP_V4, ip_normalized = TEST_IP_V4)
assert fp.ip == TEST_IP_V4
assert fp.user_agent is None
assert fp.auth_identifier is None
def test_composite_key_relaxed_ip_only(self) -> None:
fp = FingerprintFactory.anonymous()
key = fp.to_composite_key(FingerprintLevel.RELAXED)
assert key == TEST_IP_V4
def test_composite_key_relaxed_with_auth(self) -> None:
fp = FingerprintFactory.create(auth_identifier = "user_123")
key = fp.to_composite_key(FingerprintLevel.RELAXED)
assert "user_123" in key
assert TEST_IP_V4 in key
def test_composite_key_normal(self) -> None:
fp = FingerprintFactory.create(auth_identifier = "user_123")
key = fp.to_composite_key(FingerprintLevel.NORMAL)
parts = key.split(":")
assert len(parts) == 3
assert parts[0] == TEST_IP_V4
assert TEST_USER_AGENT in parts[1]
assert parts[2] == "user_123"
def test_composite_key_strict(self) -> None:
fp = FingerprintData(
ip = TEST_IP_V4,
ip_normalized = TEST_IP_V4,
user_agent = TEST_USER_AGENT,
accept_language = "en-US",
accept_encoding = "gzip",
headers_hash = "abc123",
auth_identifier = "user_456",
tls_fingerprint = "ja3hash",
geo_asn = "AS12345",
)
key = fp.to_composite_key(FingerprintLevel.STRICT)
parts = key.split(":")
assert len(parts) == 8
assert parts[0] == TEST_IP_V4
def test_factory_authenticated(self) -> None:
fp = FingerprintFactory.authenticated(auth_id = "testuser")
assert fp.auth_identifier is not None
assert len(fp.auth_identifier) == 16
def test_factory_minimal(self) -> None:
fp = FingerprintFactory.minimal()
assert fp.ip == TEST_IP_V4
assert fp.user_agent is None
class TestWindowState:
"""
Tests for WindowState and weighted count calculation
"""
def test_create_empty_state(self) -> None:
state = WindowState()
assert state.current_count == 0
assert state.previous_count == 0
def test_weighted_count_start_of_window(self) -> None:
state = WindowState(current_count = 50, previous_count = 100)
weighted = state.weighted_count(0.0)
assert weighted == 150.0
def test_weighted_count_end_of_window(self) -> None:
state = WindowState(current_count = 50, previous_count = 100)
weighted = state.weighted_count(1.0)
assert weighted == 50.0
def test_weighted_count_mid_window(self) -> None:
state = WindowState(current_count = 50, previous_count = 100)
weighted = state.weighted_count(0.5)
assert weighted == 100.0
def test_weighted_count_quarter_window(self) -> None:
state = WindowState(current_count = 40, previous_count = 80)
weighted = state.weighted_count(0.25)
assert weighted == 80 * 0.75 + 40
def test_factory_empty(self) -> None:
state = WindowStateFactory.empty()
assert state.current_count == 0
assert state.previous_count == 0
def test_factory_with_usage(self) -> None:
state = WindowStateFactory.with_usage(current = 50, previous = 100)
assert state.current_count == 50
assert state.previous_count == 100
class TestTokenBucketState:
"""
Tests for TokenBucketState
"""
def test_create_state(self) -> None:
state = TokenBucketState(
tokens = 50.0,
last_refill = 1000.0,
capacity = 100,
refill_rate = 1.67,
)
assert state.tokens == 50.0
assert state.capacity == 100
assert state.refill_rate == 1.67
def test_factory_full(self) -> None:
state = TokenBucketStateFactory.full(capacity = 200)
assert state.tokens == 200.0
assert state.capacity == 200
def test_factory_empty(self) -> None:
state = TokenBucketStateFactory.empty()
assert state.tokens == 0.0
class TestCircuitState:
"""
Tests for CircuitState
"""
def test_default_closed(self) -> None:
state = CircuitState()
assert state.is_open is False
assert state.failure_count == 0
def test_factory_closed(self) -> None:
state = CircuitStateFactory.closed()
assert state.is_open is False
def test_factory_open(self) -> None:
state = CircuitStateFactory.open()
assert state.is_open is True
assert state.failure_count == 1
assert state.last_failure_time > 0
class TestDefenseContext:
"""
Tests for DefenseContext
"""
def test_create_context(self) -> None:
fp = FingerprintFactory.create()
context = DefenseContext(
fingerprint = fp,
endpoint = TEST_ENDPOINT,
method = "GET",
is_authenticated = True,
reputation_score = 0.9,
)
assert context.endpoint == TEST_ENDPOINT
assert context.is_authenticated is True
assert context.reputation_score == 0.9
def test_factory_authenticated(self) -> None:
context = DefenseContextFactory.authenticated()
assert context.is_authenticated is True
assert context.fingerprint.auth_identifier is not None
def test_factory_suspicious(self) -> None:
context = DefenseContextFactory.suspicious(reputation_score = 0.2)
assert context.reputation_score == 0.2
assert context.request_count_last_minute == 500
class TestRateLimitKey:
"""
Tests for RateLimitKey and key building
"""
def test_build_key(self) -> None:
key = RateLimitKey(
prefix = KEY_PREFIX,
version = KEY_VERSION,
layer = Layer.USER,
endpoint = TEST_ENDPOINT,
identifier = TEST_IP_V4,
window = WINDOW_MINUTE,
)
built = key.build()
expected = f"{KEY_PREFIX}:{KEY_VERSION}:user:{TEST_ENDPOINT}:{TEST_IP_V4}:{WINDOW_MINUTE}"
assert built == expected
def test_factory_user_key(self) -> None:
key = KeyFactory.user_key()
built = key.build()
assert ":user:" in built
assert TEST_ENDPOINT in built
def test_factory_endpoint_key(self) -> None:
key = KeyFactory.endpoint_key()
built = key.build()
assert ":endpoint:" in built
assert ":global:" in built
def test_factory_global_key(self) -> None:
key = KeyFactory.global_key()
built = key.build()
assert ":global:" in built

File diff suppressed because it is too large Load Diff