Frontend Labs
⋯

Day 76 — Rate Limiting at L7

Learning Objectives

  • Implement per-API-key token bucket rate limiting
  • Return standard rate limit headers (X-RateLimit-*)
  • Build the per-entity sliding window from Day 21 into a complete middleware

Key Concepts

Token Bucket Algorithm:
  - Bucket holds up to B tokens (burst capacity)
  - Tokens refill at rate R per second
  - Each request consumes 1 token
  - If empty: return 429

Rate limit headers (standard):
  X-RateLimit-Limit:     10        (requests per window)
  X-RateLimit-Remaining: 7         (tokens left)
  X-RateLimit-Reset:     1704067200 (Unix timestamp when bucket refills)
  Retry-After:           13        (seconds to wait before retry, on 429)

Per-entity vs global:
  Per-API-key: "Stripe API allows 100 req/s"
  Per-IP:      "DDoS mitigation, before auth"
  Per-user:    "Free plan 100/day, Pro 10000/day"

Exercise: Token Bucket Rate Limiter Middleware

package ratelimit

import (
    "fmt"
    "net/http"
    "sync"
    "time"
)

type TokenBucket struct {
    mu       sync.Mutex
    tokens   float64
    capacity float64
    rate     float64 // tokens per second
    lastFill time.Time
}

func NewTokenBucket(capacity, ratePerSec float64) *TokenBucket {
    return &TokenBucket{
        tokens:   capacity,
        capacity: capacity,
        rate:     ratePerSec,
        lastFill: time.Now(),
    }
}

// Take tries to consume one token. Returns (allowed, remaining, resetAfter).
func (tb *TokenBucket) Take() (bool, float64, time.Duration) {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastFill).Seconds()
    tb.lastFill = now

    // Refill tokens based on elapsed time
    tb.tokens = min(tb.capacity, tb.tokens+elapsed*tb.rate)

    if tb.tokens < 1 {
        // Calculate when 1 token will be available
        waitSecs := (1 - tb.tokens) / tb.rate
        return false, 0, time.Duration(waitSecs * float64(time.Second))
    }

    tb.tokens--
    return true, tb.tokens, 0
}

type RateLimiter struct {
    mu       sync.Mutex
    buckets  map[string]*TokenBucket
    capacity float64
    rate     float64
    cleanup  *time.Ticker
}

func NewRateLimiter(capacity, ratePerSec float64) *RateLimiter {
    rl := &RateLimiter{
        buckets:  make(map[string]*TokenBucket),
        capacity: capacity,
        rate:     ratePerSec,
        cleanup:  time.NewTicker(5 * time.Minute),
    }
    go rl.cleanupLoop()
    return rl
}

func (rl *RateLimiter) getBucket(key string) *TokenBucket {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    if _, ok := rl.buckets[key]; !ok {
        rl.buckets[key] = NewTokenBucket(rl.capacity, rl.rate)
    }
    return rl.buckets[key]
}

func (rl *RateLimiter) cleanupLoop() {
    for range rl.cleanup.C {
        rl.mu.Lock()
        // Remove buckets that are full (haven't been used recently)
        for key, bucket := range rl.buckets {
            bucket.mu.Lock()
            if bucket.tokens >= bucket.capacity*0.99 {
                delete(rl.buckets, key)
            }
            bucket.mu.Unlock()
        }
        rl.mu.Unlock()
    }
}

// Middleware returns a per-entity rate limiting handler
// keyFn extracts the rate limit key from the request (API key, user ID, IP)
func (rl *RateLimiter) Middleware(keyFn func(*http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            key := keyFn(r)
            bucket := rl.getBucket(key)
            allowed, remaining, retryAfter := bucket.Take()

            // Always set rate limit headers
            w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%.0f", rl.capacity))
            w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%.0f", remaining))

            if !allowed {
                w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds()))
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusTooManyRequests)
                fmt.Fprintf(w, `{"error":"rate limit exceeded","retry_after":%d}`,
                    int(retryAfter.Seconds()))
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

func main() {
    // Rate limit by API key: 10 req/s burst 50
    limiter := NewRateLimiter(50, 10)

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, `{"data":"response"}`)
    })

    // Key extraction: use API key if authenticated, else IP
    keyFn := func(r *http.Request) string {
        if subject := auth.GetSubject(r.Context()); subject != "" {
            return "user:" + subject
        }
        return "ip:" + realClientIP(r)
    }

    http.ListenAndServe(":8080",
        Chain(handler,
            APIKeyAuth(store),
            limiter.Middleware(keyFn),
        ),
    )
}

Real-World Context

Stripe's API allows 100 req/s. GitHub's API allows 5000 req/hour. These are token buckets. The X-RateLimit-* headers are de facto standard — clients use them to implement polite backoff. Without Retry-After, clients retry immediately (thundering herd). With it, they wait the exact right amount.


Generated from week11.md · 8 Oct 2026 16:36