Skip to content

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

TIER_LIMITS = {
    "free": "10/minute",
    "pro": "60/minute",
}

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 to SELECT 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 = False in test conftest to avoid interfering with existing tests.
  • New test file tests/test_rate_limiting.py with limiter enabled:
  • Requests within limit succeed
  • Requests beyond limit return 429
  • Retry-After header 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