Skip to main content
Version: Next (unreleased)

HTTP Middleware

The middleware works with plain net/http, so it fits any router that accepts func(http.Handler) http.Handler — the standard ServeMux, chi, gorilla/mux, and most others.

Per-client limits​

Middleware takes a keyed limiter and derives each request's key with a key function — by default IPKeyFunc, the client address from RemoteAddr:

limiter := ratelimit.NewKeyedTokenBucket(10, 20, time.Minute)
defer limiter.Close()

handler := ratelimit.Middleware(limiter,
ratelimit.WithKeyFunc(ratelimit.IPKeyFunc),
ratelimit.WithOnLimitReached(ratelimit.JSONOnLimitReached),
)(mux)

For a single handler, Handler and HandlerFunc apply the same middleware in one call:

http.Handle("/api", ratelimit.Handler(apiHandler, limiter))
http.HandleFunc("/upload", ratelimit.HandlerFunc(upload, uploadLimiter))

One global limit​

MiddlewareFunc takes a single (non-keyed) limiter, so every request shares one limit — useful to protect a downstream dependency with a fixed capacity:

global := ratelimit.NewTokenBucket(500, 100)
handler := ratelimit.MiddlewareFunc(global)(mux)

Key functions do not apply here; WithKeyFunc is ignored.

Options​

OptionDefaultEffect
WithKeyFunc(fn)IPKeyFunchow a request maps to a key — see Key Functions
WithOnLimitReached(fn)plain-text errorwrites the response for a rejected request
WithStatusCode(code)429status used by the default rejection response
WithHeaders(enabled)truewhether to set the rate limit headers
WithSkipFunc(fn)nonerequests for which fn returns true bypass the limiter

Response headers​

With headers enabled, every response that passes through the limiter gets:

HeaderValueWhen
X-RateLimit-LimitResult.Limitalways
X-RateLimit-RemainingResult.Remainingalways
X-RateLimit-ResetResult.ResetAt as Unix secondswhen the algorithm has a window
Retry-AfterResult.RetryAfter, rounded up to whole secondswhen rejected

Token and leaky buckets refill continuously and have no reset time, so they do not send X-RateLimit-Reset.

To set the same headers from your own handler — after calling Take yourself, for example — use AddRateLimitHeaders(w, result).

Rejection responses​

Without WithOnLimitReached, a rejected request gets a plain-text Rate limit exceeded with the status from WithStatusCode (429 by default).

The package ships two JSON alternatives:

// 429 with a JSON body
ratelimit.WithOnLimitReached(ratelimit.JSONOnLimitReached)

// The same body with a different status
ratelimit.WithOnLimitReached(ratelimit.JSONOnLimitReachedWithCode(http.StatusServiceUnavailable))

Both write:

{
"error": "rate_limit_exceeded",
"message": "Too many requests",
"retry_after": 3
}

For anything else, pass your own OnLimitReached. It receives the Result, so it can report whatever the client needs:

ratelimit.WithOnLimitReached(func(w http.ResponseWriter, r *http.Request, res ratelimit.Result) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"title":"Slow down","retry_after":%d}`,
int(math.Ceil(res.RetryAfter.Seconds())))
})

:::note WithStatusCode only affects the default response

A custom OnLimitReached writes its own status. The exported DefaultOnLimitReached also always uses 429; to change the status of the plain-text response, use WithStatusCode without WithOnLimitReached.

:::

Skipping requests​

WithSkipFunc lets some requests through without touching the limiter. The package provides the common cases:

Skip functionSkips
SkipHealthChecks/health, /healthz, /ready, /readyz, /live, /livez, /ping
SkipPaths(paths...)exact path matches
SkipMethods(methods...)the listed HTTP methods, e.g. OPTIONS
SkipPrivateIPsloopback and private-range client addresses (from RemoteAddr)
SkipIf(fns...)any request matched by one of fns
ratelimit.WithSkipFunc(ratelimit.SkipIf(
ratelimit.SkipHealthChecks,
ratelimit.SkipMethods(http.MethodOptions),
ratelimit.SkipPaths("/metrics"),
))

Skipped requests get no rate limit headers.

Waiting instead of rejecting​

WaitMiddleware holds a request until the limiter allows it, up to timeout, and only rejects if the wait fails:

handler := ratelimit.WaitMiddleware(limiter, 2*time.Second,
ratelimit.WithKeyFunc(ratelimit.IPKeyFunc),
)(mux)

It smooths short spikes from trusted callers — internal services, webhooks you send yourself — at the cost of holding a goroutine and a connection per waiting request. Do not put it in front of untrusted traffic, where it turns a flood into a pile of parked requests.

The wait also ends if the client disconnects, since it uses the request's context. A rejected request reaches OnLimitReached with an empty Result, so WaitMiddleware sets no rate limit headers. A timeout of zero waits as long as the request context allows.

Different limits per route​

To give /login a stricter limit than the rest of the API, see Path Limits.