Files
mal/docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md

6.1 KiB

Rate Limiter Refactor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Refactor the global-state rate limiter into a struct-based implementation that supports multiple instances with different configurations.

Architecture:

  1. Introduce a Limiter struct that encapsulates state and configuration.
  2. Remove global variables and init() in pkg/middleware/ratelimit.go.
  3. Provide generic and auth-specific middleware methods.
  4. Integrate the new Limiter into the server startup and router.

Tech Stack: Go (Standard Library)


Task 1: Refactor Limiter Logic and State

Files:

  • Modify: pkg/middleware/ratelimit.go

  • Step 1: Define new structs and remove global state

Replace content of pkg/middleware/ratelimit.go:

package middleware

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

type visitor struct {
	attempts int
	lastSeen time.Time
}

type Config struct {
	MaxAttempts int
	Window      time.Duration
}

type Limiter struct {
	mu       sync.Mutex
	visitors map[string]*visitor
	config   Config
}

func NewLimiter(cfg Config) *Limiter {
	return &Limiter{
		visitors: make(map[string]*visitor),
		config:   cfg,
	}
}

func (l *Limiter) Cleanup(now time.Time) {
	l.mu.Lock()
	defer l.mu.Unlock()
	for ip, v := range l.visitors {
		if now.Sub(v.lastSeen) > l.config.Window*3 {
			delete(l.visitors, ip)
		}
	}
}

func getIP(r *http.Request) string {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		ips := strings.Split(xff, ",")
		return strings.TrimSpace(ips[0])
	}
	if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
		return realIP
	}
	ip := r.RemoteAddr
	if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 {
		ip = ip[:colonIdx]
	}
	return ip
}
  • Step 2: Implement Middleware methods

Add to pkg/middleware/ratelimit.go:

func (l *Limiter) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !l.allow(getIP(r)) {
			http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (l *Limiter) AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !l.allow(getIP(r)) {
			if strings.HasPrefix(r.URL.Path, "/") {
				http.Redirect(w, r, fmt.Sprintf("%s?error=rate_limited", r.URL.Path), http.StatusFound)
				return
			}
			http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (l *Limiter) allow(ip string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	v, exists := l.visitors[ip]
	if !exists {
		l.visitors[ip] = &visitor{1, time.Now()}
		return true
	}

	if time.Since(v.lastSeen) > l.config.Window {
		v.attempts = 1
		v.lastSeen = time.Now()
		return true
	}

	v.attempts++
	v.lastSeen = time.Now()
	return v.attempts <= l.config.MaxAttempts
}
  • Step 3: Commit refactor
git add pkg/middleware/ratelimit.go
git commit -m "refactor: convert rate limiter to struct-based implementation"

Task 2: Add Unit Tests for Limiter

Files:

  • Create: pkg/middleware/ratelimit_test.go

  • Step 1: Write tests for Limiter logic

Create pkg/middleware/ratelimit_test.go:

package middleware

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

func testHandler() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
}

func TestLimiter(t *testing.T) {
	cfg := Config{MaxAttempts: 2, Window: 100 * time.Millisecond}
	l := NewLimiter(cfg)
	handler := l.Middleware(testHandler())

	// First attempt
	req := httptest.NewRequest("GET", "/", nil)
	req.RemoteAddr = "1.2.3.4:1234"
	rr := httptest.NewRecorder()
	handler.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rr.Code)
	}

	// Second attempt
	rr = httptest.NewRecorder()
	handler.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rr.Code)
	}

	// Third attempt (should fail)
	rr = httptest.NewRecorder()
	handler.ServeHTTP(rr, req)
	if rr.Code != http.StatusTooManyRequests {
		t.Errorf("expected 429, got %d", rr.Code)
	}

	// Wait for window to expire
	time.Sleep(150 * time.Millisecond)

	// Fourth attempt (should pass again)
	rr = httptest.NewRecorder()
	handler.ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Errorf("expected 200, got %d", rr.Code)
	}
}
  • Step 2: Run tests

Run: go test -v pkg/middleware/ratelimit.go pkg/middleware/ratelimit_test.go Expected: PASS

  • Step 3: Commit tests
git add pkg/middleware/ratelimit_test.go
git commit -m "test: add unit tests for rate limiter"

Task 3: Integrate Limiter into Server

Files:

  • Modify: internal/server/routes.go

  • Modify: cmd/server/main.go

  • Step 1: Update Config and NewRouter

Modify internal/server/routes.go:

type Config struct {
	DB                  *db.Queries
	SQLDB               *sql.DB
	JikanClient         *jikan.Client
	AuthService         *auth.Service
	PlaybackProxySecret string
	AuthLimiter         *pkgmiddleware.Limiter // Add this
}

// ... in NewRouter function, replace:
// pkgmiddleware.RateLimitAuth(...)
// with:
// cfg.AuthLimiter.AuthMiddleware(...)
  • Step 2: Instantiate Limiter in main.go

Modify cmd/server/main.go:

// ... in main function
authLimiter := pkgmiddleware.NewLimiter(pkgmiddleware.Config{
    MaxAttempts: 5,
    Window:      time.Minute,
})

// Start cleanup goroutine
go func() {
    for {
        time.Sleep(time.Minute)
        authLimiter.Cleanup(time.Now())
    }
}()

router := server.NewRouter(server.Config{
    // ...
    AuthLimiter: authLimiter,
})
  • Step 3: Verify build and manual test

Run: go build ./... Expected: PASS

  • Step 4: Commit integration
git add internal/server/routes.go cmd/server/main.go
git commit -m "feat: integrate new rate limiter into server"