package ratelimit import ( "encoding/json" "log/slog" "net/http" "strconv" "time" ) // KeyFunc derives the rate-limit key from a request — e.g. IP, IP+email, // authenticated user id, path token, etc. An empty return value bypasses // the limiter (handy when a key isn't available yet, like an email that // hasn't been parsed out of the body). type KeyFunc func(r *http.Request) string // Rule names a single sliding-window budget. type Rule struct { Name string Limit int Window time.Duration } // Middleware returns an http middleware that applies `rule` to incoming // requests using `keyFn`. On limit, it writes 429 with a Retry-After header // and a JSON body. If the limiter itself errors, requests are allowed // (fail-open) — degraded protection is better than total outage. func (l *Limiter) Middleware(rule Rule, keyFn KeyFunc, logger *slog.Logger) 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) if key == "" { next.ServeHTTP(w, r) return } res, err := l.Allow(r.Context(), rule.Name, key, rule.Limit, rule.Window) if err != nil { if logger != nil { logger.Warn("ratelimit error (failing open)", "rule", rule.Name, "err", err) } next.ServeHTTP(w, r) return } if !res.Allowed { retrySecs := int(res.RetryAfter.Round(time.Second).Seconds()) if retrySecs < 1 { retrySecs = 1 } w.Header().Set("Retry-After", strconv.Itoa(retrySecs)) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusTooManyRequests) _ = json.NewEncoder(w).Encode(map[string]any{ "error": "rate limit exceeded", "retry_after": retrySecs, }) if logger != nil { logger.Info("ratelimit blocked", "rule", rule.Name, "key", key, "retry_after", retrySecs, ) } return } next.ServeHTTP(w, r) }) } }