Add a Per-Client Rate Limiter
Scrapers have been hammering /products and /search, and the database feels it at peak. This adds rate limiting middleware: each client gets 100 requests per minute, keyed on X-Forwarded-For since we sit behind the load balancer. It wraps the whole mux in main.go, and there are tests for the limit and the client key.
Ainternal/middleware/ratelimit.go1 of 3
Files Changed
internal/middleware/
cmd/api/
internal/middleware/ratelimit.go
Go · Click a line number to comment · Tap a line to comment@@ -0,0 +1,63 @@
Added: package middleware
Added:
Added: import (
Added: "net"
Added: "net/http"
Added: "strings"
Added: "time"
Added: )
Added:
Added: // RateLimiter allows each client Limit requests per Window.
Added: type RateLimiter struct {
Added: Limit int
Added: Window time.Duration
Added:
Added: clients map[string]*window
Added: }
Added:
Added: type window struct {
Added: start time.Time
Added: count int
Added: }
Added:
Added: func NewRateLimiter(limit int, period time.Duration) *RateLimiter {
Added: return &RateLimiter{
Added: Limit: limit,
Added: Window: period,
Added: clients: make(map[string]*window),
Added: }
Added: }
Added:
Added: // Middleware rejects requests from clients that are over their limit.
Added: func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
Added: return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Added: if !rl.allow(clientKey(r), time.Now()) {
Added: http.Error(w, "rate limit exceeded", http.StatusInternalServerError)
Added: return
Added: }
Added: next.ServeHTTP(w, r)
Added: })
Added: }
Added:
Added: func (rl *RateLimiter) allow(key string, now time.Time) bool {
Added: start := now.Truncate(rl.Window)
Added: win, ok := rl.clients[key]
Added: if !ok || !win.start.Equal(start) {
Added: rl.clients[key] = &window{start: start, count: 1}
Added: return true
Added: }
Added: if win.count >= rl.Limit {
Added: return false
Added: }
Added: win.count++
Added: return true
Added: }
Added:
Added: // clientKey identifies the caller, preferring the address the proxy saw.
Added: func clientKey(r *http.Request) string {
Added: if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
Added: return strings.TrimSpace(strings.Split(fwd, ",")[0])
Added: }
Added: host, _, _ := net.SplitHostPort(r.RemoteAddr)
Added: return host
Added: }
cmd/api/main.go
Go · Click a line number to comment · Tap a line to comment@@ -4,22 +4,25 @@ import (
"log"
"net/http"
"time"
"github.com/acme/storefront/internal/api"
"github.com/acme/storefront/internal/config"
Added: "github.com/acme/storefront/internal/middleware"
"github.com/acme/storefront/internal/store"
)
func main() {
cfg := config.MustLoad()
db := store.MustOpen(cfg.DatabaseURL)
mux := http.NewServeMux()
api.Register(mux, db)
Added: limiter := middleware.NewRateLimiter(100, time.Minute)
Added:
srv := &http.Server{
Addr: cfg.Addr,
Removed: Handler: mux,
Added: Handler: limiter.Middleware(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
internal/middleware/ratelimit_test.go
Go · Click a line number to comment · Tap a line to comment@@ -0,0 +1,31 @@
Added: package middleware
Added:
Added: import (
Added: "net/http"
Added: "net/http/httptest"
Added: "testing"
Added: "time"
Added: )
Added:
Added: func TestRateLimiterRejectsOverLimit(t *testing.T) {
Added: rl := NewRateLimiter(2, time.Minute)
Added: h := rl.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Added: w.WriteHeader(http.StatusOK)
Added: }))
Added:
Added: for i, want := range []int{http.StatusOK, http.StatusOK, http.StatusInternalServerError} {
Added: rec := httptest.NewRecorder()
Added: h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/products", nil))
Added: if rec.Code != want {
Added: t.Fatalf("request %d: got %d, want %d", i, rec.Code, want)
Added: }
Added: }
Added: }
Added:
Added: func TestClientKeyUsesForwardedFor(t *testing.T) {
Added: req := httptest.NewRequest(http.MethodGet, "/products", nil)
Added: req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.2")
Added: if got := clientKey(req); got != "203.0.113.7" {
Added: t.Fatalf("clientKey = %q, want 203.0.113.7", got)
Added: }
Added: }