Documentation
¶
Overview ¶
Package ratelimit provides token-bucket rate limiting as net/http middleware, with a pluggable storage backend.
Algorithm ¶
A token bucket, not a fixed window. A fixed-window counter permits up to twice the intended rate across a window boundary: a client can spend its whole quota in the last instant of one window and its whole quota again in the first instant of the next. A token bucket has no boundary to exploit, while still allowing the short bursts that user-facing APIs want.
Threat model ¶
Rate limiting mitigates credential stuffing, scraping, and brute force. It is only as good as its key, and the two ways it is most often defeated are:
- Spoofed client identity. The default key is the request's RemoteAddr, never X-Forwarded-For. Forwarded headers are set by the client and are trivially forged; a limiter keyed on an unverified X-Forwarded-For is not a limiter at all, because every request can claim a fresh identity. Behind a trusted proxy, use WithKeyFunc with a function that reads the forwarded header only after confirming the peer really is that proxy.
- Memory exhaustion of the limiter itself. A map keyed by client address grows with the number of distinct sources, and a single host with an IPv6 /64 can mint effectively unlimited addresses. MemoryStore therefore caps the number of tracked keys, discarding the most refilled bucket rather than the least recently used one — see MemoryStore for why that distinction is the difference between a bound and a bypass.
A per-process MemoryStore enforces its limit per instance. Behind a load balancer with N instances, the effective limit is N times the configured one; use a shared store (see the redisstore module) when that matters.
Failure policy ¶
When the store returns an error the limiter fails closed by default: the request is rejected and the WithOnError hook is invoked. Fail-closed is a deliberate and debatable choice — for pure abuse mitigation, availability is often worth more than enforcement — so it is configurable through WithFailureMode. It is the default so that failing to make a decision leaves you with the safer behavior.
A genuine denial and a backend outage are answered with different status codes (429 and 503 respectively), so that an outage is not silently indistinguishable from ordinary throttling in your metrics.
The package never logs or prints. Errors reach the application only through WithOnError and through returned values.
Index ¶
- Constants
- Variables
- func RemoteAddrKey(r *http.Request) (string, error)
- type Config
- type FailureMode
- type HeaderMode
- type KeyFunc
- type Limiter
- func (l *Limiter) Allow(ctx context.Context, key string) bool
- func (l *Limiter) AllowN(ctx context.Context, key string, n int) bool
- func (l *Limiter) Close() error
- func (l *Limiter) Config() Config
- func (l *Limiter) Middleware(next http.Handler) http.Handler
- func (l *Limiter) Take(ctx context.Context, key string) (Result, error)
- func (l *Limiter) TakeN(ctx context.Context, key string, n int) (Result, error)
- type MemoryOption
- type MemoryStore
- type Option
- type Result
- type Store
Examples ¶
Constants ¶
const ( DefaultMaxKeys = 100_000 DefaultIdleTTL = 10 * time.Minute )
Default bounds for MemoryStore. See WithMaxKeys and WithIdleTTL for the reasoning behind each.
Variables ¶
var ErrClosed = errors.New("ratelimit: store is closed")
ErrClosed is returned by store operations attempted after Close. It exists so that a shutdown race is reported as such rather than as a silent denial.
var ErrNoKey = errors.New("ratelimit: cannot derive a rate-limit key for the request")
ErrNoKey reports that the client could not be identified, so no bucket could be selected.
A custom KeyFunc should wrap it when it declines to trust a request — for example when a forwarded header arrives from a peer that is not the expected proxy. The middleware also reports it to WithOnError when a KeyFunc returns an empty key. Test for it with errors.Is, so that an unidentifiable client can be told apart from a store outage in your own error handling.
Functions ¶
func RemoteAddrKey ¶
RemoteAddrKey keys buckets by the IP portion of r.RemoteAddr, which is the peer of the TCP connection and cannot be forged by the client without controlling the routing between you.
The port is stripped, because it changes on every connection and would give each request its own bucket.
Behind a reverse proxy every request appears to come from the proxy, so all clients share one bucket. That is a availability problem, not a security hole, and the fix is a KeyFunc that reads the forwarded header after verifying the peer — see WithKeyFunc.
Types ¶
type Config ¶
type Config struct {
// Burst is the bucket capacity: the maximum number of requests that may be
// made back to back after an idle period. Values below 1 are treated as 1.
Burst int
// PerSecond is the sustained rate at which tokens are replenished.
// Zero, negative, and NaN are treated as zero, which means the bucket never
// refills: once Burst requests are spent, everything is denied. That is the
// fail-closed reading of a misconfiguration, and it is deliberate.
PerSecond float64
}
Config describes the shape of a bucket: how many requests may arrive at once and how quickly the allowance is replenished.
It is passed to [Store.Take] on every call rather than held by the store, so that a single shared store can serve limiters with different limits, and so that a network-backed store stays stateless with respect to configuration.
type FailureMode ¶
type FailureMode int
FailureMode selects what happens when the Store returns an error.
const ( // FailClosed rejects the request when the store errors. This is the default: // an outage must not silently disable the control that protects the // endpoints behind it. FailClosed FailureMode = iota // FailOpen admits the request when the store errors, trading enforcement for // availability. Choose it deliberately, for endpoints where being // unreachable is worse than being unthrottled. FailOpen )
Failure modes. FailClosed is the zero value, so a Limiter that is never told what to do about a store outage does the safer thing.
type HeaderMode ¶
type HeaderMode int
HeaderMode selects which rate-limit headers are written to responses.
Publishing limit state helps well-behaved clients back off, and marginally helps an attacker tune their request rate to stay just under the threshold. That is a trade-off worth making consciously, so it is configurable and can be switched off entirely.
const ( // HeaderBoth emits the widely deployed X-RateLimit-* headers and the // standardized RateLimit-* form. This is the default. HeaderBoth HeaderMode = iota // HeaderLegacy emits only X-RateLimit-Limit, X-RateLimit-Remaining and // X-RateLimit-Reset. HeaderLegacy // HeaderStandard emits only RateLimit-Limit, RateLimit-Remaining and // RateLimit-Reset. HeaderStandard // HeaderNone emits no rate-limit headers. Retry-After is still sent on a // denial, since a client that has just been rejected needs to know when to // come back. HeaderNone )
Header modes. HeaderBoth is the zero value.
type KeyFunc ¶
KeyFunc derives the rate-limit bucket key for a request.
Returning an error rejects the request without consulting the store, and is the correct response to a request whose identity cannot be established — for example a forwarded header arriving from an untrusted peer. Do not return a constant fallback key in that situation: it merges every unidentifiable client into one bucket, so a single attacker can exhaust the allowance of everyone who shares it.
An empty key with a nil error is treated as an error, for the same reason.
type Limiter ¶
type Limiter struct {
// contains filtered or unexported fields
}
Limiter applies a token-bucket limit to HTTP requests.
A Limiter is safe for concurrent use and is intended to be created once at startup and shared. Create it with New; the zero value is not usable.
func New ¶
New returns a Limiter allowing bursts of burst requests, replenished at perSecond requests per second.
With no options it uses a bounded in-process MemoryStore, keys buckets by RemoteAddrKey, fails closed on store errors, and emits both families of rate-limit headers.
burst below 1 is treated as 1, and a perSecond that is zero, negative or NaN is treated as zero, meaning the bucket never refills. Misconfiguration therefore over-restricts rather than silently disabling the limiter.
If the Limiter created the store itself, Limiter.Close shuts it down; a store supplied through WithStore is left to its owner.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/JonasBorgesLM/moat/ratelimit"
)
func main() {
// A burst of 3 requests. The refill rate is deliberately glacial here so
// that the output is deterministic no matter how the test machine is
// scheduled -- an example that depends on wall-clock timing is a flaky
// test, and a flaky test teaches people to ignore red CI.
limiter := ratelimit.New(3, 0.0001)
defer limiter.Close()
handler := limiter.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}))
// The fourth request from the same address is rejected.
var codes []int
for range 4 {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.10:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
codes = append(codes, rec.Code)
}
fmt.Println(codes)
}
Output: [200 200 200 429]
func (*Limiter) Allow ¶ added in v0.2.0
Allow reports whether a request for key may proceed, consuming one token if it may.
It is equivalent to AllowN(ctx, key, 1).
func (*Limiter) AllowN ¶ added in v0.2.0
AllowN reports whether an operation for key costing n tokens may proceed, consuming them if it may.
This is the form to use inside a handler, where the identity that should be limited — an account ID, an API key, a tenant — is known only after authentication and so cannot be derived by the middleware from the request alone:
if !limiter.AllowN(r.Context(), account.ID, report.Cost()) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
Unlike Limiter.TakeN it applies the configured FailureMode: a store outage returns false under FailClosed and true under FailOpen, and either way the error reaches WithOnError. That is the whole difference between the two — use TakeN when you want to see the error and decide yourself.
It deliberately returns a bare bool rather than a Result. A caller that wants the remaining allowance or a retry hint is asking a different question and should call TakeN, whose answer includes both; folding them into this signature would make the common case carry values it does not use.
n below 1 is treated as 1, and n above Config.Burst is always denied.
func (*Limiter) Close ¶
Close releases the Limiter's store if the Limiter created it. A store passed to WithStore is left untouched, since its lifetime belongs to whoever created it. Close is safe to call more than once.
func (*Limiter) Middleware ¶
Middleware applies the limit to every request reaching next.
It is a plain func(http.Handler) http.Handler, so it can be used directly or placed in a middleware.Chain. Put it early in the pipeline: a limiter that runs after expensive work has already paid for the request it was meant to reject.
The middleware never reads the request body, so it is safe to place before body-reading middleware and handlers.
func (*Limiter) Take ¶
Take consumes one token for key, bypassing HTTP entirely.
It is equivalent to TakeN(ctx, key, 1). It applies no failure policy: the caller decides what a non-nil error means. For the common case where the answer wanted is a yes or no with the configured failure policy already applied, use Limiter.Allow.
Example ¶
ExampleLimiter_Take applies the same limit outside HTTP — a queue consumer, a background job — without a second configuration to keep in sync. This example is compile-only: it depends on the wall clock.
package main
import (
"context"
"time"
"github.com/JonasBorgesLM/moat/ratelimit"
)
func main() {
limiter := ratelimit.New(5, 1)
defer limiter.Close()
res, err := limiter.Take(context.Background(), "tenant-42")
if err != nil {
// The store failed; decide for yourself what that means here — Take
// applies no failure policy of its own.
return
}
if !res.Allowed {
time.Sleep(res.RetryAfter)
}
}
Output:
func (*Limiter) TakeN ¶ added in v0.2.0
TakeN consumes n tokens for key, bypassing HTTP entirely.
It is exported so that the same limit can cover non-HTTP work — a background job, a gRPC call, a queue consumer — without a second configuration to keep in sync, and so that expensive operations can cost more than cheap ones.
n below 1 is treated as 1. An n above Config.Burst can never be satisfied and is always denied, with a RetryAfter that reflects a wait that will never end; a weight larger than the bucket is a configuration error, and reporting it as a permanent denial is the fail-closed reading of one.
The whole deduction is atomic: n tokens are taken or none are, in a single indivisible store operation. Calling Take in a loop is not equivalent, and is racy — see Store on why.
It applies no failure policy: the caller decides what a non-nil error means.
type MemoryOption ¶
type MemoryOption func(*MemoryStore)
MemoryOption configures a MemoryStore.
func WithIdleTTL ¶
func WithIdleTTL(d time.Duration) MemoryOption
WithIdleTTL sets how long an untouched bucket is kept.
The TTL is a floor, not a ceiling: a bucket is never reclaimed before it would have refilled to full, regardless of this setting, because discarding a partially refilled bucket hands its client a fresh allowance. Shortening the TTL below the refill window therefore saves no memory and creates no bypass — it is simply ignored for buckets that are still meaningful.
Reclaiming may lag the TTL by up to one refill window, because buckets are ordered by when they refill rather than by when they were last touched. The TTL is a memory-hygiene threshold, not a deadline, and lagging it costs a bounded amount of memory that the key cap already limits.
Values below or equal to zero are ignored.
func WithMaxKeys ¶
func WithMaxKeys(n int) MemoryOption
WithMaxKeys caps how many buckets are tracked at once.
The cap is the store's own memory bound, so set it from the memory you are willing to spend rather than from the number of clients you expect: roughly 130 bytes per tracked key plus the key itself.
What the cap costs when it binds ¶
Reaching the cap forces a bucket out, and forgetting a bucket grants its client a fresh burst. That is a rate-limit bypass, not a fairness problem: the effective limit for the client whose bucket is dropped stops being the rate you configured and becomes the rate at which the store turns over. Saying so plainly matters, because the two readings call for different sizing — a fairness glitch is something you tolerate, and a bypass of the limiter on /login is not.
The store keeps that from being reachable in the ordinary case by discarding the most-refilled bucket rather than the least recently used one: a bucket that has refilled to capacity grants nothing when dropped, so as long as any refilled bucket exists, the cap costs exactly nothing. See MemoryStore.
The residual case is a store in which *every* tracked bucket is still in debt. Then the bound wins — the map does not grow past the cap — and the bucket closest to full is dropped, which is the smallest grant on offer. Getting there means keeping maxKeys distinct clients simultaneously below capacity, which costs an attacker maxKeys x PerSecond requests per second sustained, every one of them already counted against a bucket. Sizing MaxKeys above your realistic distinct-client count keeps it out of reach.
Values below 1 are ignored.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is a bounded in-process Store.
Why it is bounded ¶
The obvious implementation, a map from key to bucket, is itself a denial-of-service vector: memory grows with the number of distinct keys, and with an IP-based key a single host controlling an IPv6 /64 can mint effectively unlimited distinct addresses. MemoryStore therefore caps the number of tracked buckets and discards entries to stay under the cap, in addition to reclaiming buckets that have been idle long enough to be irrelevant.
Discarding a bucket is granting an allowance ¶
This is the property the whole design turns on: forgetting a bucket gives its client a fresh full burst. So a discarded bucket must be one whose loss grants nothing, which means a bucket that had already refilled to capacity — for such a bucket, keeping it and forgetting it are the same thing.
The store therefore orders buckets by *how refilled they are* and always discards from the most-refilled end, never from the least-recently-used end. The distinction is the difference between a bound and a bypass. Recency is anti-correlated with safety here: a client that has just exhausted its burst stops being able to make requests, so its bucket stops being touched and drifts toward the least-recently-used end — an LRU policy therefore selects, by construction, the bucket whose loss grants the most. Refill order selects the opposite, and an exhausted bucket only becomes a candidate once it has refilled, at which point it is free to drop.
Under enough pressure — every tracked bucket still in debt, none refilled — something must still go, because the memory bound is not negotiable. The store discards the bucket closest to full, which is the smallest grant available. See WithMaxKeys for what that costs and how to size around it.
Lifetime ¶
MemoryStore runs no background goroutine. Expired buckets are reclaimed during Take, amortized over the calls that follow them. A janitor goroutine would be a lifetime hazard for every caller who forgets to Close, and a store that leaks goroutines is a poor foundation for a library that is supposed to be safe by default.
State is per process. Behind a load balancer with N instances the effective limit is N times the configured one; use a shared store when that matters.
MemoryStore is safe for concurrent use. Create it with NewMemoryStore; the zero value is not usable.
func NewMemoryStore ¶
func NewMemoryStore(opts ...MemoryOption) *MemoryStore
NewMemoryStore returns an in-process store bounded by DefaultMaxKeys and DefaultIdleTTL unless overridden.
Example ¶
ExampleNewMemoryStore configures the store's memory bound explicitly. The cap is what keeps an attacker minting unlimited distinct keys from exhausting the limiter's own memory.
package main
import (
"fmt"
"time"
"github.com/JonasBorgesLM/moat/ratelimit"
)
func main() {
store := ratelimit.NewMemoryStore(
ratelimit.WithMaxKeys(50_000),
ratelimit.WithIdleTTL(15*time.Minute),
)
defer store.Close()
limiter := ratelimit.New(20, 5, ratelimit.WithStore(store))
defer limiter.Close()
fmt.Println(store.Len())
}
Output: 0
func (*MemoryStore) Close ¶
func (s *MemoryStore) Close() error
Close discards all state. Subsequent calls to Take return ErrClosed rather than silently allowing traffic. It is safe to call more than once.
func (*MemoryStore) Len ¶
func (s *MemoryStore) Len() int
Len reports how many buckets are currently tracked. It exists for tests and for exporting a gauge; it is not part of the Store contract.
func (*MemoryStore) TakeN ¶ added in v0.2.0
func (s *MemoryStore) TakeN(ctx context.Context, key string, n int, cfg Config, now time.Time) (Result, error)
Take implements Store.
It is atomic under a mutex: refill, decision and write-back happen without another Take on the same key interleaving, so concurrent requests cannot each observe the same pre-decrement state and all be admitted.
The supplied now is used as the clock; the store never reads the wall clock itself. Because the elapsed time is derived from the stored timestamp, a now that moves backwards (an NTP step, a test) refills nothing rather than crediting tokens.
type Option ¶
type Option func(*Limiter)
Option configures a Limiter.
func WithDeniedHandler ¶
WithDeniedHandler replaces the response written when a client is over its limit. The default writes 429 with a Retry-After header.
The rate-limit headers, including Retry-After, are already set on the ResponseWriter when the handler runs, so a custom handler can render any body it likes without recomputing them. A nil handler is ignored.
func WithFailureMode ¶
func WithFailureMode(mode FailureMode) Option
WithFailureMode selects whether a store error rejects or admits the request. The default is FailClosed.
func WithHeaders ¶
func WithHeaders(mode HeaderMode) Option
WithHeaders selects which rate-limit headers are published. The default is HeaderBoth.
func WithKeyFunc ¶
WithKeyFunc replaces the function that derives a bucket key from a request.
This is the single most security-sensitive knob in the package. The default deliberately ignores X-Forwarded-For and X-Real-IP because those are supplied by the client: keying on them lets an attacker present a new identity with every request and bypass the limit entirely. Only read a forwarded header after verifying that r.RemoteAddr belongs to a proxy you control, and prefer taking a fixed number of hops from the right-hand end of the header, which is the part your own proxy appended.
A nil fn is ignored.
Example ¶
ExampleWithKeyFunc shows the only safe way to use a forwarded header: verify that the immediate peer is a proxy you control, and only then trust what it appended. Reading X-Forwarded-For without that check makes the limiter a no-op, because the client chooses its own value.
package main
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"github.com/JonasBorgesLM/moat/ratelimit"
)
func main() {
// The addresses of your own load balancers.
trustedProxies := []net.IP{net.ParseIP("10.0.0.1")}
isTrusted := func(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
ip := net.ParseIP(host)
for _, p := range trustedProxies {
if ip != nil && ip.Equal(p) {
return true
}
}
return false
}
keyFunc := func(r *http.Request) (string, error) {
if !isTrusted(r.RemoteAddr) {
// Not our proxy: the forwarded header is unverified input. Refuse
// rather than falling back to a shared bucket, which one attacker
// could drain on everyone else's behalf.
return "", fmt.Errorf("%w: peer %q is not a trusted proxy", ratelimit.ErrNoKey, r.RemoteAddr)
}
// Take the right-most entry: the one our own proxy appended. Everything
// to its left was supplied by the client.
xff := r.Header.Values("X-Forwarded-For")
if len(xff) == 0 {
return "", ratelimit.ErrNoKey
}
last := xff[len(xff)-1]
for i := len(last) - 1; i >= 0; i-- {
if last[i] == ',' {
return trimSpace(last[i+1:]), nil
}
}
return trimSpace(last), nil
}
limiter := ratelimit.New(20, 5, ratelimit.WithKeyFunc(keyFunc))
defer limiter.Close()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:4444"
req.Header.Set("X-Forwarded-For", "198.51.100.7, 203.0.113.9")
key, err := keyFunc(req)
fmt.Println(key, err)
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
Output: 203.0.113.9 <nil>
func WithOnError ¶
WithOnError registers a hook invoked when the store fails or a key cannot be derived.
This library never logs; the hook is how store outages, which are otherwise invisible behind a generic rejection, reach your logger, metrics and alerts. Without it, a fail-closed limiter in front of a dead backend rejects every request with no signal to operators.
The hook runs synchronously on the request path, so it must not block. It must not panic; a panic in the hook propagates to the server.
Example ¶
ExampleWithOnError shows how a backend outage becomes visible. The library never logs; without a hook, a fail-closed limiter in front of a dead store rejects everything with no signal to operators.
package main
import (
"fmt"
"github.com/JonasBorgesLM/moat/ratelimit"
)
func main() {
limiter := ratelimit.New(20, 5,
ratelimit.WithOnError(func(err error) {
// Route into your own logger and metrics. Runs on the request path,
// so it must not block.
_ = err
}),
// Availability matters more than enforcement on this endpoint.
ratelimit.WithFailureMode(ratelimit.FailOpen),
)
defer limiter.Close()
fmt.Println(limiter.Config().Burst)
}
Output: 20
func WithStore ¶
WithStore makes the Limiter use store instead of the default in-process one.
Use it to share limit state across instances — a per-process store enforces its limit once per instance, so N instances behind a load balancer allow N times the configured rate. The caller keeps ownership: Limiter.Close does not close a store supplied here, because one store is normally shared by several limiters.
A nil store is ignored, leaving the default in place, so that a failed store constructor cannot accidentally disable limiting.
func WithStoreErrorHandler ¶
WithStoreErrorHandler replaces the response written when the store fails and the failure mode is FailClosed. The default writes 503.
It is deliberately distinct from the denied handler: answering an outage with 429 tells clients to slow down when the truth is that the limiter cannot make a decision, and makes the outage indistinguishable from real throttling. A nil handler is ignored.
type Result ¶
type Result struct {
// Allowed reports whether a token was available and consumed.
Allowed bool
// Remaining is the whole number of tokens left after this call. It is never
// negative.
Remaining int
// RetryAfter is how long until at least one token is available. It is zero
// when a token is available now, and is the value reported to clients in
// Retry-After and in the reset headers.
RetryAfter time.Duration
}
Result is the outcome of one [Store.Take].
type Store ¶
type Store interface {
// TakeN consumes n tokens from the bucket identified by key, creating a
// full bucket if it does not exist.
//
// All n are taken or none are: a partial deduction would charge a caller
// for work it was not allowed to do. n below 1 must be treated as 1, so
// that a miscomputed weight cannot turn into a free request.
//
// An n larger than cfg.Burst can never be satisfied, and must be reported
// as a denial rather than as an error — it is a request that costs more
// than the bucket can ever hold, which is a decision, not a malfunction.
TakeN(ctx context.Context, key string, n int, cfg Config, now time.Time) (Result, error)
// Close releases any resources held by the store. It must be safe to call
// more than once.
Close() error
}
Store holds bucket state. Implementations must be safe for concurrent use.
Contract ¶
Take must be atomic: read, refill, decide, and write back without interleaving with another Take on the same key. A read-modify-write split across round trips races, and the race is exploitable — concurrent requests each read the same pre-decrement state and all get admitted, which is exactly the burst an attacker wants.
The context is mandatory rather than optional because any network-backed store needs a deadline: without one, a hung backend converts a rate limiter into a request-stalling denial of service. The error is mandatory so that "the backend is down" is distinguishable from "this client is over its limit"; returning a plain denial for an outage leaves operators blind.
now is supplied by the caller so that tests can drive time deterministically. Implementations must not substitute their own clock for it.