Codetail

Article 11 of 15

Rate Limiting

Token bucket, leaky bucket, and sliding windows.

16 min read

Why Rate Limiting Exists

A public API endpoint without rate limiting is a shared resource with no admission control. A single misbehaving client, whether a bug, a bot, or an attacker, can consume the capacity that was provisioned for thousands of users. Rate limiting is the mechanism by which a system enforces fairness: each client gets a bounded share of capacity, regardless of how aggressively they request.

The goals are distinct but often conflated. Availability protection keeps a single client from taking down the service for everyone else. Security prevents brute force and credential stuffing. Cost control bounds the bill when a compute-heavy endpoint is called at scale. Fairness ensures that a paying customer's experience is not degraded by a free-tier user consuming resources without bound.

What happens without rate limiting

Credential stuffing

Without rate limiting

An attacker tries 100,000 password combinations against /auth/login. Each attempt is a valid HTTP request. Success rate: 0.1% = 100 accounts compromised.

With rate limiting

5 login attempts per IP per minute. After 5 failures: 429. Attack requires 20,000 IPs to maintain speed, making it economically unviable.

Accidental DDoS (runaway client)

Without rate limiting

A bug in a client retries a failing endpoint in a tight loop. 50 clients x 1000 req/sec = 50,000 req/sec hitting your API. Database falls over.

With rate limiting

100 requests per minute per API key. Runaway client gets 429 after 100 requests. Database load stays bounded.

Web scraping

Without rate limiting

A competitor scrapes your product catalog at 500 req/sec, exporting your pricing data to a spreadsheet. Your CDN bill quadruples.

With rate limiting

10 requests per second per IP on catalog endpoints. Scraper throttled to 1/50th of original speed, making bulk extraction impractical.

Expensive endpoint abuse

Without rate limiting

Your /reports/generate endpoint triggers a 30-second database query. A single user hammers it 10 times concurrently. Database CPU at 100%, all other queries slow.

With rate limiting

2 concurrent requests per user on /reports. Excess requests queued or rejected immediately. Other users unaffected.

Dimensions of rate limiting

By identity

Limit per API key, per user ID, per session token. Authenticated clients get individual quotas. One bad client cannot affect others.

By IP address

Limit per source IP. Effective against unauthenticated endpoints (login, registration). Problematic for shared NATs (corporate offices, mobile carriers).

By endpoint

Different limits for different routes. /reports (expensive) gets 2/min. /ping (cheap) gets 1000/min. Match the limit to the cost of the operation.

By tier

Free tier: 100 requests/day. Pro tier: 10,000/day. Enterprise: unlimited. Rate limits become a product feature and a monetization lever.

By resource

Limit by tokens spent, not requests counted. An AI API charges 1 token for a short completion and 1000 for a long one. Token buckets match resource consumption.

By time granularity

Per-second limits prevent bursts. Per-day limits allow flexible usage patterns. Layering both (10/sec AND 10,000/day) provides burst protection and quota enforcement.

Rate Limiting Algorithms

Rate limiting algorithms differ in how they track usage over time and how they handle bursts. The choice affects memory usage, accuracy at window boundaries, and whether you allow short bursts above the average rate. The three most important to understand are token bucket, fixed window, and sliding window.

Rate Limiter Algorithm Simulator

Bucket holds 8 tokens. Refills at 1/sec. Each request costs 1 token. Burst up to capacity, then limited to refill rate.

Tokens in bucket8 / 8

Refill rate: 1 token/sec, burst capacity: 8

Token bucket

The token bucket holds a maximum of N tokens. Tokens are added at a fixed rate (R per second). Each request consumes one token. If the bucket has tokens, the request is allowed. If empty, it is rejected. The bucket accumulates tokens while traffic is low, enabling bursts up to capacity when traffic spikes.

This is the algorithm behind most production rate limiters because burst handling maps to real-world usage: a user typing quickly generates short bursts of API calls, while a background job makes steady calls over time. Both are valid. Token bucket handles both correctly with one parameter set.

Token bucket in Redis (Lua script)

-- Called atomically via EVAL
local key        = KEYS[1]
local capacity   = tonumber(ARGV[1])   -- max tokens
local refill_rate = tonumber(ARGV[2])  -- tokens per second
local now        = tonumber(ARGV[3])   -- current Unix timestamp (ms)
local cost       = tonumber(ARGV[4])   -- tokens this request costs

local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens     = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- Refill tokens based on elapsed time
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens >= cost then
  tokens = tokens - cost
  redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
  redis.call("EXPIRE", key, 3600)
  return 1   -- allowed
else
  redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
  redis.call("EXPIRE", key, 3600)
  return 0   -- rejected
end

Fixed window

Fixed window increments a counter for the current time window. The counter resets when the window expires. Implementation is a single Redis INCR with an expiry: trivially simple and extremely fast.

The weakness is the boundary burst. With a limit of 100/minute, a client can send 100 requests in the last second of minute 1 and 100 in the first second of minute 2, 200 requests in 2 seconds without violating the limit. If your system cannot handle a 2x burst, fixed window is insufficient.

Fixed window in Redis

def is_allowed(user_id: str, limit: int, window_sec: int) -> bool:
    now = int(time.time())
    window_key = now // window_sec   # floor to window boundary
    key = f"rl:{user_id}:{window_key}"

    count = redis.incr(key)
    if count == 1:
        redis.expire(key, window_sec * 2)  # safety: 2x TTL
    return count <= limit

Sliding window

Sliding window eliminates the boundary burst by tracking the exact time of each request within the window. On each request, timestamps older than (now - window_size) are removed. If the remaining count is below the limit, the request is allowed and its timestamp recorded.

The sliding window counter approximation is more practical at scale: instead of storing every timestamp, it combines the current window's counter with the previous window's counter, weighted by how much of the previous window overlaps with the current sliding window. This trades marginal accuracy for O(1) memory.

Algorithm comparison

AlgorithmBurstMemoryAccuracyComplexityBest for
Token BucketYes, up to capacityO(1) per clientExactLowAPIs where bursts are acceptable: upload endpoints, webhook delivery
Leaky BucketNo, constant output rateO(1) per clientExactLowSmooth traffic to downstream: protecting a database, payment processor
Fixed WindowYes, at window boundaryO(1) per clientApproximateVery lowSimple quota enforcement: API keys with daily/hourly limits
Sliding Window LogControlledO(requests in window) per clientExactMediumPrecise rate limiting where boundary bursts are unacceptable
Sliding Window CounterControlledO(1) per clientApproximateLowBest of both: accurate enough, memory-efficient

Implementation: Distributed Rate Limiting

A single-process rate limiter is trivial. The challenge in production is distributed rate limiting: multiple application instances must share the same counter so that a client cannot bypass the limit by routing requests across different servers. Redis is the standard solution, a single Redis cluster holds all rate limit state, and application instances check against it on every request.

The critical requirement is atomicity. An INCR followed by an expiry check is not atomic: two simultaneous requests can both read "0" and both allow themselves, counting one request but allowing two. Redis Lua scripts run atomically (the server executes the entire script before processing any other command), making them the correct implementation primitive for rate limiters.

Application middleware pattern (FastAPI)

import time
from fastapi import Request, HTTPException
from redis.asyncio import Redis

redis = Redis(host="localhost", decode_responses=True)

RATE_LIMITS = {
    "free":       (100,  60),   # 100 req / 60 sec
    "pro":        (1000, 60),
    "enterprise": (None, None), # unlimited
}

async def rate_limit_middleware(request: Request, call_next):
    user = request.state.user           # set by auth middleware
    tier = user.subscription_tier       # "free" | "pro" | "enterprise"
    limit, window = RATE_LIMITS[tier]

    if limit is None:                   # enterprise: skip check
        return await call_next(request)

    key = f"rl:{user.id}:{int(time.time()) // window}"
    count = await redis.incr(key)
    if count == 1:
        await redis.expire(key, window * 2)

    remaining = max(0, limit - count)
    reset_at = (int(time.time()) // window + 1) * window

    if count > limit:
        raise HTTPException(
            status_code=429,
            detail="Rate limit exceeded",
            headers={
                "X-RateLimit-Limit":     str(limit),
                "X-RateLimit-Remaining": "0",
                "X-RateLimit-Reset":     str(reset_at),
                "Retry-After":           str(reset_at - int(time.time())),
            },
        )

    response = await call_next(request)
    response.headers["X-RateLimit-Limit"]     = str(limit)
    response.headers["X-RateLimit-Remaining"] = str(remaining)
    response.headers["X-RateLimit-Reset"]     = str(reset_at)
    return response

429 response best practices

Always include Retry-After

Clients that receive a 429 without Retry-After have no choice but to retry immediately, creating a storm. Retry-After tells the client exactly how long to wait. Most HTTP clients and SDK retry logic honors it.

Return a structured error body

The 429 body should be machine-readable: { "error": "rate_limit_exceeded", "limit": 100, "reset_at": 1735689600 }. Clients can log it, surface it to users, or adjust retry strategy based on the reset time.

Never silently drop requests

A 429 is informative. Silently dropping the request (connection timeout, no response) is not. The client does not know whether to retry or whether the request was processed.

Return remaining quota on all 2xx responses

Include X-RateLimit-Remaining on every successful response, not just when the limit is near. Well-behaved clients use this to self-throttle before they hit the limit.

Use exponential backoff with jitter on the client

When a client hits a 429, retrying after exactly Retry-After seconds means all rate-limited clients retry simultaneously. Add jitter: wait Retry-After + random(0, 1) seconds to spread the retry load.

Standard response headers

HeaderExampleMeaning
X-RateLimit-Limit100Maximum requests allowed in the current window
X-RateLimit-Remaining43Requests remaining in the current window
X-RateLimit-Reset1735689600Unix timestamp when the window resets
Retry-After47Seconds until the client can retry (on 429 responses)
X-RateLimit-Policy100;w=60Machine-readable policy (IETF draft standard)

Where to enforce rate limits

API Gateway / Reverse proxy

Nginx, Kong, Envoy, AWS API Gateway

Pro: Applied before request reaches application code. Protects all downstream services uniformly. No code change in application.

Con: Coarse-grained: all routes share the same limit unless gateway config supports per-route rules. Less context (no user tier).

Use when: Global protection against unauthenticated abuse, IP-based limiting, and DDoS mitigation.

Application middleware

FastAPI middleware, Django middleware, Express middleware

Pro: Full request context available: user ID, subscription tier, endpoint cost. Per-user and per-tier limits are straightforward.

Con: Applied after network I/O. Application still handles the incoming request (a cost). Must share rate limit state via Redis in multi-instance deployments.

Use when: Authenticated API rate limiting, tier-based quotas, per-endpoint limits.

CDN edge

Cloudflare Rate Limiting, Fastly, Akamai

Pro: Blocking happens at the network edge, before traffic reaches your infrastructure. Protects against volumetric attacks at global scale.

Con: Limited to IP or cookie-based identification. Cannot make application-level decisions (user tier, endpoint cost).

Use when: DDoS mitigation, scraping protection, unauthenticated endpoints.