httprate

package module
v0.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 29, 2026 License: MIT Imports: 9 Imported by: 256

README

httprate - HTTP Rate Limiter

CI workflow Benchmark workflow GoDoc Widget

net/http request rate limiter based on the Sliding Window Counter pattern inspired by CloudFlare https://blog.cloudflare.com/counting-things-a-lot-of-different-things.

[!WARNING] Security: LimitByRealIP / KeyByRealIP / WithKeyByRealIP are deprecated. They derive the rate-limit key from client-supplied headers (True-Client-IP, X-Real-IP, X-Forwarded-For) with no proxy-trust check, so a remote attacker can spoof the key — either rotating it to evade the limit or pinning it to a victim's IP to lock that victim out (HTTP 429). This is the same flaw fixed in chi's middleware.RealIP (see GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx). Rate-limit by a trusted client IP instead — see Rate limit by client IP behind a proxy.

The sliding window counter pattern is accurate, smooths traffic and offers a simple counter design to share a rate-limit among a cluster of servers. For example, if you'd like to use redis to coordinate a rate-limit across a group of microservices you just need to implement the httprate.LimitCounter interface to support an atomic increment and get.

Backends

Example

package main

import (
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/httprate"
)

func main() {
	r := chi.NewRouter()
	r.Use(middleware.Logger)

	// Enable httprate request limiter of 100 requests per minute, keyed by the
	// client IP.
	//
	// There is no safe default IP source, so you state your trust model
	// explicitly: one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+)
	// resolves the client IP, and the KeyFunc reads it back with
	// middleware.GetClientIP. Pick the chi ClientIPFrom* that matches your
	// deployment — here we assume the server is directly exposed to clients (no
	// proxy), so the client IP is the TCP peer (RemoteAddr). Behind a reverse
	// proxy or CDN, use ClientIPFromXFF / ClientIPFromHeader instead; see "Rate
	// limit by client IP behind a proxy" below.
	//
	// httprate.CanonicalizeIP buckets IPv6 clients by their /64 so they can't
	// rotate within it to win fresh buckets (see that section for why).
	//
	// To have a single rate-limiter for all requests, use a constant key:
	// httprate.LimitBy(.., httprate.Key("*")).
	//
	// Please see _example/main.go for more, or read the library code.
	r.Use(middleware.ClientIPFromRemoteAddr)
	r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
		return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
	}))

	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("."))
	})

	http.ListenAndServe(":3333", r)
}

Common use cases

Rate limit by client IP behind a proxy

If your app runs behind a reverse proxy, load balancer, or CDN, the request's RemoteAddr is the proxy, not the client. Resolving the real client IP safely means deciding which hop to trust — there is no safe default, and trusting a client-supplied header blindly is exactly the spoofing bug behind the deprecated LimitByRealIP.

Use chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) to resolve a trusted client IP, then rate-limit by it with a LimitBy KeyFunc that reads it back and canonicalizes it:

import (
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/httprate"
)

// 1. Resolve a trusted client IP. Pick exactly ONE that matches your
//    deployment (see the table below):
r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))

// 2. Rate-limit by that trusted client IP.
r.Use(httprate.LimitBy(100, time.Minute, clientIPKey))

// clientIPKey is the rate-limit key. middleware.GetClientIP reads the IP
// resolved in step 1; httprate.CanonicalizeIP buckets IPv6 clients by /64.
func clientIPKey(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}

[!NOTE] middleware.GetClientIP returns the full client IP. Keying on it directly lets an IPv6 client rotate within its own /64 (2^64 addresses via SLAAC) to get a fresh bucket per request and bypass the limit. httprate.CanonicalizeIP buckets IPv6 by /64 (IPv4 unchanged) — copy its logic and widen the prefix (e.g. /56, /48) if your clients are delegated a larger block. The deprecated KeyByIP / KeyByRealIP did this canonicalization for you; CanonicalizeIP keeps it while making the trust model and prefix your choice.

Pick the one ClientIPFrom* middleware that matches how requests reach you:

Your setup Use
Directly on the public internet, no proxy middleware.ClientIPFromRemoteAddr
Behind nginx (X-Real-IP), Cloudflare (CF-Connecting-IP), Apache (X-Client-IP) middleware.ClientIPFromHeader("X-Real-IP")
Behind one or more proxies whose IP ranges you can list middleware.ClientIPFromXFF("10.0.0.0/8", ...)
Behind a known, fixed number of proxies with dynamic IPs middleware.ClientIPFromXFFTrustedProxies(2)

See chi's Choosing a ClientIP middleware for the full picker.

[!IMPORTANT] If no ClientIPFrom* middleware is installed upstream, middleware.GetClientIP returns "" and every request shares a single global rate-limit bucket. That's strictly more restrictive (not a security hole), but it's a footgun — you'll trip the limit after requestLimit total requests in dev. Make sure exactly one ClientIPFrom* middleware runs before the limiter.

A KeyFunc is just func(r *http.Request) (string, error), so it isn't tied to chi — read the client IP (or tenant/user ID) from wherever echo, fiber, gin, or your own middleware stashes it on the request, and return it.

Rate limit by IP and URL path (aka endpoint)
// clientIPKey is the KeyFunc from "Rate limit by client IP behind a proxy" above.
r.Use(httprate.LimitBy(
	10,             // requests
	10*time.Second, // per duration
	httprate.JoinKeys(clientIPKey, httprate.KeyByEndpoint),
))
Rate limit by arbitrary keys
r.Use(httprate.LimitBy(
	100,
	time.Minute,
	// an oversimplified example of rate limiting by a custom header
	func(r *http.Request) (string, error) {
		return r.Header.Get("X-Access-Token"), nil
	},
))
Rate limit by request payload
// Rate-limiter for login endpoint.
loginRateLimiter := httprate.NewRateLimiter(5, time.Minute)

r.Post("/login", func(w http.ResponseWriter, r *http.Request) {
	var payload struct {
		Username string `json:"username"`
		Password string `json:"password"`
	}
	err := json.NewDecoder(r.Body).Decode(&payload)
	if err != nil || payload.Username == "" || payload.Password == "" {
		w.WriteHeader(400)
		return
	}

	// Rate-limit login at 5 req/min.
	if loginRateLimiter.RespondOnLimit(w, r, payload.Username) {
		return
	}

	w.Write([]byte("login at 5 req/min\n"))
})
Send specific response for rate-limited requests

The default response is HTTP 429 with Too Many Requests body. You can override it with:

r.Use(httprate.LimitBy(
	10,
	time.Minute,
	clientIPKey, // the KeyFunc from "Rate limit by client IP behind a proxy" above
	httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, `{"error": "Rate-limited. Please, slow down."}`, http.StatusTooManyRequests)
	}),
))
Send specific response on errors

An error can be returned by:

  • A custom key function provided by httprate.WithKeyFunc(customKeyFn)
  • A custom backend provided by httprateredis.WithRedisLimitCounter(customBackend)
    • The default local in-memory counter is guaranteed not return any errors
    • Backends that fall-back to the local in-memory counter (e.g. httprate-redis) can choose not to return any errors either
r.Use(httprate.LimitBy(
	10,
	time.Minute,
	clientIPKey, // the KeyFunc from "Rate limit by client IP behind a proxy" above
	httprate.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
		http.Error(w, fmt.Sprintf(`{"error": %q}`, err), http.StatusPreconditionRequired)
	}),
	httprate.WithLimitCounter(customBackend),
))
Send custom response headers
r.Use(httprate.LimitBy(
	1000,
	time.Minute,
	clientIPKey, // the KeyFunc from "Rate limit by client IP behind a proxy" above
	httprate.WithResponseHeaders(httprate.ResponseHeaders{
		Limit:      "X-RateLimit-Limit",
		Remaining:  "X-RateLimit-Remaining",
		Reset:      "X-RateLimit-Reset",
		RetryAfter: "Retry-After",
		Increment:  "", // omit
	}),
))
Omit response headers
r.Use(httprate.LimitBy(
	1000,
	time.Minute,
	clientIPKey, // the KeyFunc from "Rate limit by client IP behind a proxy" above
	httprate.WithResponseHeaders(httprate.ResponseHeaders{}),
))

LICENSE

MIT

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CanonicalizeIP added in v0.16.0

func CanonicalizeIP(ip string) string

CanonicalizeIP normalizes a client IP string for use as a rate-limit key:

  • IPv4 addresses are returned unchanged.
  • IPv6 addresses are reduced to their /64 prefix. An IPv6 client typically controls a whole /64 (2^64 addresses via SLAAC), so keying on the full address would let it rotate within its own /64 to win a fresh bucket per request and bypass a per-IP limit. Widen/narrow the prefix yourself if your clients are delegated a larger block (e.g. a /56 or /48).
  • Any other string, including "", is returned unchanged.

httprate stays router-agnostic, so it does not resolve the client IP for you — pair CanonicalizeIP with whatever does. With chi's middleware.ClientIPFrom* (chi v5.3.0+) and middleware.GetClientIP:

r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

WARNING: if the resolver returns "" (e.g. no ClientIPFrom* middleware is installed upstream), CanonicalizeIP returns "" and every request shares a single global rate-limit bucket. Strictly more restrictive, but a footgun — make sure the client IP is actually resolved upstream.

func Key added in v0.14.1

func Key(key string) func(r *http.Request) (string, error)

func KeyByEndpoint

func KeyByEndpoint(r *http.Request) (string, error)

func KeyByIP deprecated

func KeyByIP(r *http.Request) (string, error)

Deprecated: KeyByIP keys off r.RemoteAddr, the TCP peer that opened the connection. Unlike KeyByRealIP it is NOT spoofable (RemoteAddr is set by net/http, never from a header) — but behind a reverse proxy, load balancer, or CDN, r.RemoteAddr is the proxy's address, so every client sharing that proxy lands in one rate-limit bucket. That's usually the wrong key in production, and there is no safe default IP source.

State your trust model explicitly with one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP (CanonicalizeIP buckets IPv6 by /64):

// Directly exposed to clients (KeyByIP's exact behavior, made explicit):
r.Use(middleware.ClientIPFromRemoteAddr)
r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

// Behind a proxy: use ClientIPFromXFF / ClientIPFromHeader / ... instead.

KeyByIP returns the IPv4 address unchanged, or the /64 prefix for IPv6.

func KeyByRealIP deprecated added in v0.6.0

func KeyByRealIP(r *http.Request) (string, error)

Deprecated: KeyByRealIP trusts the client-supplied True-Client-IP, X-Real-IP, and X-Forwarded-For headers without verifying any proxy chain, so a remote attacker can forge the rate-limit key — see GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx for the equivalent flaw in chi's middleware.RealIP. On a rate-limiter this is two-sided: an attacker can evade the limit by rotating the spoofed header (unbounded buckets) or lock a victim out by pinning the header to the victim's IP (exhausting their bucket).

Install one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP instead (CanonicalizeIP buckets IPv6 by /64):

r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

func Limit deprecated

func Limit(requestLimit int, windowLength time.Duration, options ...Option) func(next http.Handler) http.Handler

Deprecated: Use LimitBy(requestLimit, windowLength, keyFn, options...) instead, which makes the rate-limit key an explicit, required argument rather than an optional WithKeyFuncs. Pass the key function directly (e.g. a KeyFunc that reads a trusted client IP — see CanonicalizeIP), or httprate.Key("*") for a single global bucket. The remaining options (WithLimitCounter, WithLimitHandler, WithResponseHeaders, ...) carry over unchanged as LimitBy's trailing variadic.

func LimitAll deprecated

func LimitAll(requestLimit int, windowLength time.Duration) func(next http.Handler) http.Handler

Deprecated: Use LimitBy(requestLimit, windowLength, Key("*")) instead — a single global rate-limit bucket keyed by a constant. (LimitAll already keys every request by "*" under the hood; this just makes that explicit.)

func LimitBy added in v0.16.0

func LimitBy(requestLimit int, windowLength time.Duration, keyFn KeyFunc, options ...Option) func(next http.Handler) http.Handler

LimitBy is the canonical entry point for rate-limiting by an explicit key.

It is shorthand for Limit with keyFn installed as the rate-limit key. The key is a required positional argument, so every call site has to state, on purpose, what it rate-limits by.

To rate-limit by a trusted client IP behind a proxy, resolve the IP with one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and read it back in the KeyFunc; CanonicalizeIP buckets IPv6 clients by their /64:

r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

Use JoinKeys to rate-limit by more than one dimension at once:

r.Use(httprate.LimitBy(100, time.Minute,
	httprate.JoinKeys(clientIPKey, httprate.KeyByEndpoint)))

func LimitByIP deprecated

func LimitByIP(requestLimit int, windowLength time.Duration) func(next http.Handler) http.Handler

Deprecated: LimitByIP keys off r.RemoteAddr (see KeyByIP). It is not spoofable, but behind a reverse proxy, load balancer, or CDN r.RemoteAddr is the proxy's address, so every client sharing that proxy lands in one bucket — usually the wrong key in production. State your trust model explicitly: install one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP (CanonicalizeIP buckets IPv6 by /64):

// Directly exposed to clients (LimitByIP's exact behavior, made explicit):
r.Use(middleware.ClientIPFromRemoteAddr)
r.Use(httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

func LimitByRealIP deprecated added in v0.6.0

func LimitByRealIP(requestLimit int, windowLength time.Duration) func(next http.Handler) http.Handler

Deprecated: LimitByRealIP is built on the spoofable KeyByRealIP and lets a remote attacker forge the rate-limit key — see GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx for the equivalent flaw in chi's middleware.RealIP. Install one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP instead (CanonicalizeIP buckets IPv6 by /64):

r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
r.Use(httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
	return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
}))

func LimitCounterKey

func LimitCounterKey(key string, window time.Time) uint64

func NewLocalLimitCounter added in v0.12.0

func NewLocalLimitCounter(windowLength time.Duration) *localCounter

NewLocalLimitCounter creates an instance of localCounter, which is an in-memory implementation of http.LimitCounter.

All methods are guaranteed to always return nil error.

func WithIncrement added in v0.8.0

func WithIncrement(ctx context.Context, value int) context.Context

func WithRequestLimit added in v0.9.0

func WithRequestLimit(ctx context.Context, value int) context.Context

Types

type KeyFunc

type KeyFunc func(r *http.Request) (string, error)

func JoinKeys added in v0.16.0

func JoinKeys(fns ...KeyFunc) KeyFunc

JoinKeys joins the results of several KeyFuncs into a single key with ":" separators, so they can be passed to LimitBy's positional key slot for multi-dimensional rate-limiting:

r.Use(httprate.LimitBy(100, time.Minute,
	httprate.JoinKeys(clientIPKey, httprate.KeyByEndpoint)))

where clientIPKey is your own client-IP KeyFunc (see LimitBy and CanonicalizeIP). It is the positional-argument equivalent of WithKeyFuncs. If any component KeyFunc returns an error, the joined key returns that error.

type LimitCounter

type LimitCounter interface {
	Config(requestLimit int, windowLength time.Duration)
	Increment(key string, currentWindow time.Time) error
	IncrementBy(key string, currentWindow time.Time, amount int) error
	Get(key string, currentWindow, previousWindow time.Time) (int, int, error)
}

type Option

type Option func(rl *RateLimiter)

func WithErrorHandler added in v0.12.1

func WithErrorHandler(h func(http.ResponseWriter, *http.Request, error)) Option

func WithKeyByIP deprecated added in v0.7.0

func WithKeyByIP() Option

Deprecated: WithKeyByIP installs KeyByIP, which keys off r.RemoteAddr — the proxy's address behind a reverse proxy, load balancer, or CDN, and usually the wrong key in production (see KeyByIP). It is not a spoofing issue, but there is no safe default IP source. State your trust model explicitly: install one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP with LimitBy instead (see CanonicalizeIP).

func WithKeyByRealIP deprecated added in v0.7.0

func WithKeyByRealIP() Option

Deprecated: WithKeyByRealIP installs the spoofable KeyByRealIP and lets a remote attacker forge the rate-limit key — see GHSA-9g5q-2w5x-hmxf, GHSA-rjr7-jggh-pgcp, GHSA-3fxj-6jh8-hvhx for the equivalent flaw in chi's middleware.RealIP. Install one of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and key off the resolved IP with LimitBy instead (see CanonicalizeIP).

func WithKeyFuncs

func WithKeyFuncs(keyFuncs ...KeyFunc) Option

func WithLimitCounter

func WithLimitCounter(c LimitCounter) Option

func WithLimitHandler

func WithLimitHandler(h http.HandlerFunc) Option

func WithNoop added in v0.7.3

func WithNoop() Option

func WithResponseHeaders added in v0.11.0

func WithResponseHeaders(headers ResponseHeaders) Option

type RateLimiter added in v0.13.1

type RateLimiter struct {
	// contains filtered or unexported fields
}

func NewRateLimiter

func NewRateLimiter(requestLimit int, windowLength time.Duration, options ...Option) *RateLimiter

func (*RateLimiter) Counter added in v0.13.1

func (l *RateLimiter) Counter() LimitCounter

func (*RateLimiter) Handler added in v0.13.1

func (l *RateLimiter) Handler(next http.Handler) http.Handler

func (*RateLimiter) OnLimit added in v0.13.1

func (l *RateLimiter) OnLimit(w http.ResponseWriter, r *http.Request, key string) bool

OnLimit checks the rate limit for the given key and updates the response headers accordingly. If the limit is reached, it returns true, indicating that the request should be halted. Otherwise, it increments the request count and returns false. This method does not send an HTTP response, so the caller must handle the response themselves or use the RespondOnLimit() method instead.

func (*RateLimiter) RespondOnLimit added in v0.14.0

func (l *RateLimiter) RespondOnLimit(w http.ResponseWriter, r *http.Request, key string) bool

RespondOnLimit checks the rate limit for the given key and updates the response headers accordingly. If the limit is reached, it automatically sends an HTTP response and returns true, signaling the caller to halt further request processing. If the limit is not reached, it increments the request count and returns false, allowing the request to proceed.

func (*RateLimiter) Status added in v0.13.1

func (l *RateLimiter) Status(key string) (bool, float64, error)

type ResponseHeaders added in v0.11.0

type ResponseHeaders struct {
	Limit      string // Default: X-RateLimit-Limit
	Remaining  string // Default: X-RateLimit-Remaining
	Increment  string // Default: X-RateLimit-Increment
	Reset      string // Default: X-RateLimit-Reset
	RetryAfter string // Default: Retry-After
}

Set custom response headers. If empty, the header is omitted.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL