Rate Limiting Design¶
Issue: #3 Date: 2026-05-04
Goal¶
Add per-key and per-IP rate limiting to prevent abuse: link creation spam, redirect flooding, and webhook amplification. Webhook delivery rate limiting is deferred to a separate issue.
Approach¶
Use slowapi (in-memory storage) for HTTP request rate limiting. Counters reset on deploy/restart, which is acceptable for MVP. Upgrade to Redis-backed storage later if needed.
Limits¶
| Endpoint | Key | Free tier | Pro tier / Master |
|---|---|---|---|
POST /shorten |
API key hash | 10/minute | 60/minute |
GET /{short_id} |
Client IP | 100/minute | 100/minute |
All other endpoints (health, QR, API key management) are exempt.
Architecture¶
Limiter Setup¶
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
Tier Limits Config¶
POST /shorten — Per-Key, Tier-Aware¶
A custom key function hashes the API key for use as the limiter key. A dynamic limit function looks up the tier and returns the appropriate rate string.
def get_api_key_identifier(request: Request) -> str:
api_key = request.headers.get("X-API-Key", "")
return hashlib.sha256(api_key.encode()).hexdigest()
def get_shorten_limit(request: Request) -> str:
api_key = request.headers.get("X-API-Key", "")
try:
api_key_id, tier = authenticate_request(api_key)
except HTTPException:
return "10/minute" # auth will reject anyway
request.state.api_key_id = api_key_id
request.state.api_key_tier = tier
if api_key_id is None:
return TIER_LIMITS.get("pro", "60/minute")
return TIER_LIMITS.get(tier, "10/minute")
The endpoint body reads request.state.api_key_id instead of calling authenticate_request again, avoiding double auth + double DB query.
GET /{short_id} — Per-IP¶
Static limit using slowapi's built-in get_remote_address:
@app.get("/{short_id}")
@limiter.limit("100/minute")
async def redirect_link(request: Request, short_id: str, ...):
Error Response¶
slowapi returns 429 Too Many Requests with Retry-After header automatically via the default exception handler. No custom response format needed.
Changes to Existing Code¶
authenticate_request Returns Tuple¶
Change return type from Optional[UUID] to tuple[Optional[UUID], str]:
- Master key:
(None, "pro") - DB key:
(UUID, tier)— extend the existing SELECT toSELECT id, tier - Invalid key: raises 401 (unchanged)
All Callers Updated¶
Every call site destructures the new return type:
- POST /shorten: uses both api_key_id and tier (via request.state from limit function)
- POST /api-keys, GET /api-keys, DELETE /api-keys/{key_id}, POST /api-keys/{key_id}/rotate: api_key_id, _ = authenticate_request(...)
Endpoint Signatures¶
POST /shorten and GET /{short_id} get request: Request added to their function signatures (required by slowapi).
Testing¶
- Default:
limiter.enabled = Falsein test conftest to avoid interfering with existing tests. - New test file
tests/test_rate_limiting.pywith limiter enabled: - Requests within limit succeed
- Requests beyond limit return 429
Retry-Afterheader present on 429- Free tier limit is lower than pro tier
- Master key gets pro limits
- Redirect endpoint limits by IP
New Dependency¶
slowapi added to requirements.txt.
Out of Scope¶
- Webhook delivery rate limiting (per-key daily cap) — deferred to separate issue
- Redis/persistent storage — upgrade path for when we scale beyond single instance