Documentation
¶
Overview ¶
Package ratelimit provides a generic, sliding-window HTTP rate-limit middleware suitable for guarding sensitive endpoints (login, signup, password reset, etc.).
The middleware is storage-agnostic via the Store interface. Use NewMemoryStore for the common single-node case or NewCacheStore to wrap a github.com/rakunlabs/cache instance (e.g. redis-backed) for cluster-wide limits. The cache TTL/capacity invariants are encapsulated in those constructors so callers cannot accidentally weaken the defense by misconfiguring the cache.
All time-related logic uses tummy.Now() so tests can advance the clock deterministically.
Typical usage:
store, _ := ratelimit.NewMemoryStore(10_000)
mw := ratelimit.Middleware(ratelimit.Config{
Window: 15 * time.Minute,
SoftThreshold: 3, HardThreshold: 30,
BackoffBase: time.Second, BackoffMax: 15 * time.Second,
KeyFunc: func(r *http.Request) []string {
return []string{"ip:" + clientIP(r)}
},
ShouldCount: func(_ *http.Request, status int) bool {
return status == http.StatusUnauthorized
},
Store: store,
})
Index ¶
- Constants
- func KeyByIP(r *http.Request) string
- func KeyByRealIP(r *http.Request) string
- func LimitAll(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler
- func LimitByIP(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler
- func LimitByRealIP(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler
- func Middleware(cfg Config) func(http.Handler) http.Handler
- type Bucket
- type Config
- type Decision
- type RejectReason
- type Store
Constants ¶
const DefaultMemoryCapacity = 10_000
DefaultMemoryCapacity is the LRU cap applied by NewMemoryStore when the caller passes a non-positive capacity. 10_000 entries is enough to hold independent buckets for a sustained attack while staying well under 5 MB.
Variables ¶
This section is empty.
Functions ¶
func KeyByIP ¶ added in v0.4.4
KeyByIP returns the canonical client IP from r.RemoteAddr (port stripped).
func KeyByRealIP ¶ added in v0.4.4
KeyByRealIP returns the client IP, trusting common proxy headers first.
- True-Client-IP / X-Real-IP are used verbatim (single IP).
- X-Forwarded-For uses the first (left-most) entry.
- Falls back to r.RemoteAddr when no header is present.
func LimitAll ¶ added in v0.4.4
LimitAll limits every request through a single shared counter, regardless of client. Equivalent to httprate.LimitAll.
func LimitByIP ¶ added in v0.4.4
LimitByIP limits requests per client IP, taken from r.RemoteAddr. Equivalent to httprate.LimitByIP.
func LimitByRealIP ¶ added in v0.4.4
LimitByRealIP limits requests per client IP, preferring the proxy-supplied real IP headers (True-Client-IP, X-Real-IP, X-Forwarded-For) and falling back to r.RemoteAddr. Equivalent to httprate.LimitByRealIP.
func Middleware ¶
Middleware returns the http middleware enforcing cfg. Per-request flow:
- KeyFunc(r) → if empty, pass through unmodified.
- For each key: a. Read bucket; drop entries older than Window. b. If len(attempts) >= HardThreshold, write 429 + OnReject and stop. c. Else compute backoff for the highest current count across keys.
- Sleep for the computed backoff (if > 0).
- Invoke next with a status-capturing ResponseWriter.
- If ShouldCount(r, status), append tummy.Now() to every key's bucket, persist, and call OnAttempt once per key.
Concurrency: bucket reads/writes are atomic per key via an internal mutex pool. Two requests for the same key will serialize their read-update-write cycle; requests for different keys run in parallel.
Types ¶
type Bucket ¶
type Bucket struct {
// Attempts is the timestamps of counted attempts within the window,
// oldest first. Stale entries (older than Window) are pruned on every
// read.
Attempts []time.Time
}
Bucket holds the per-key attempt history for one window. The package stores instances in the configured cache. Exported so callers can inspect state in tests; instances must be treated as immutable outside of the limiter (the limiter takes a fresh copy on every Set).
type Config ¶
type Config struct {
// Window is the sliding-window length. Attempts older than this are
// dropped from the bucket on every observation.
Window time.Duration `cfg:"window"`
// SoftThreshold is the count at or above which the middleware sleeps
// before invoking the handler. Set to 0 to disable backoff.
SoftThreshold int `cfg:"soft_threshold"`
// HardThreshold is the count at or above which the middleware rejects
// the request with 429 and never invokes the handler. Set to 0 to
// disable hard rejection.
HardThreshold int `cfg:"hard_threshold"`
// BackoffBase is the base of the exponential delay. The delay applied
// at count = SoftThreshold + n is min(BackoffBase * 2^n, BackoffMax).
// A zero BackoffBase disables the delay even when SoftThreshold trips.
BackoffBase time.Duration `cfg:"backoff_base"`
// BackoffMax caps the per-request delay. Zero means uncapped, which is
// almost certainly wrong for production — set this to bound how long a
// single request can hold a goroutine.
BackoffMax time.Duration `cfg:"backoff_max"`
// KeyFunc returns one or more keys to count this request under. Return
// nil or empty to skip the limiter for this request (useful for
// non-applicable paths). Each key is independently counted and any one
// of them tripping HardThreshold rejects the request.
KeyFunc func(*http.Request) []string
// ShouldCount is invoked AFTER the wrapped handler runs with the
// captured response status. Return true to count this request against
// every key. Typical: status == 401 || status == 400.
ShouldCount func(r *http.Request, status int) bool
// OnReject is called when a key trips HardThreshold and the request is
// blocked. Useful for audit logging. Optional.
OnReject func(r *http.Request, key string, reason RejectReason, retryAfter time.Duration)
// OnAttempt is called for every counted attempt with the post-handler
// decision (one call per key). Optional.
OnAttempt func(r *http.Request, decision Decision, status int)
// Store is the backing storage for attempt buckets. Use
// NewMemoryStore for single-node pika or NewCacheStore to plug in a
// redis-backed cache for cluster-wide limits. Required.
Store Store
}
Config controls the middleware. Window, SoftThreshold, HardThreshold, KeyFunc, ShouldCount and Store are required; the rest are optional.
type Decision ¶
type Decision struct {
// Key is the limiter key (Config.KeyFunc output) this decision is for.
Key string
// Count is the number of counted hits within the current window AFTER
// this attempt is recorded.
Count int
// Delay is the backoff applied before the wrapped handler ran.
Delay time.Duration
// Rejected is true when the request was rejected with 429 (handler did
// not run).
Rejected bool
// RetryAfter is the Retry-After value sent on a reject.
RetryAfter time.Duration
}
Decision is the limiter's per-key evaluation outcome. It is passed to OnAttempt for observability after the wrapped handler runs.
type RejectReason ¶
type RejectReason string
RejectReason describes why a request was rejected.
const ( // ReasonHardThreshold means the per-key counter exceeded HardThreshold // within Window. The middleware responded with 429 Too Many Requests // and a Retry-After header. ReasonHardThreshold RejectReason = "hard_threshold" )
type Store ¶
type Store interface {
// Get returns the bucket for key. A non-existent key yields (nil, false, nil).
Get(ctx context.Context, key string) (*Bucket, bool, error)
// Set persists the bucket for key, replacing any prior value.
Set(ctx context.Context, key string, b *Bucket) error
}
Store is the backing storage contract for the rate-limit middleware. It is intentionally minimal: the limiter only needs to fetch the current attempt bucket for a key and write an updated one back.
Contract:
- Get returns (nil, false, nil) when the key is absent. Absence is not an error; only IO/serialization failures are.
- Set replaces any existing value for key. There is no delete — stale buckets are either pruned in-place by the limiter on next read or evicted by the store under memory pressure.
- Implementations are allowed to evict keys at any time (LRU, capacity limit, etc.). The limiter is correctness-preserving under eviction: a missing key simply looks like "no attempts yet", which cannot falsely block a legitimate user.
Typical implementations:
- NewMemoryStore — in-process, LRU-bounded, for single-node pika
- NewCacheStore — adapter for github.com/rakunlabs/cache (redis etc.)
Custom implementations (mock, database-backed, etc.) are encouraged; the interface is small by design.
func NewCacheStore ¶
NewCacheStore adapts an existing *cache.Cache into the ratelimit Store interface. Use this when you need a non-default backend (for example github.com/rakunlabs/cache/store/redis for cross-process limits across several pika instances).
The caller is responsible for configuring the underlying cache with TTL=0: the limiter prunes per-attempt timestamps authoritatively on every read, and a TTL on the cache would risk dropping an active bucket mid-window, silently weakening the defense. Eviction under memory pressure (LRU cap or redis key expiry driven by external policy) is fine — a missing bucket just looks like "no attempts yet" to the limiter, which is strictly safer than falsely blocking a user.
For the common case (single-node in-memory), prefer NewMemoryStore, which applies the right config for you.
func NewMemoryStore ¶
NewMemoryStore returns an in-process Store backed by rakunlabs/cache's LRU memory backend. Capacity is the LRU cap; when reached, the least- recently-used bucket is dropped. Pass 0 (or negative) for the default.
The cache is intentionally constructed with TTL=0 (no automatic expiration). The limiter prunes per-attempt timestamps inside each Bucket on every read, so authoritative window expiry is handled at the limiter level. A TTL here would add a second, coarser expiry that could drop an active bucket mid-window — silently weakening the defense. Memory pressure is bounded by the LRU cap instead.
The returned Store is safe for concurrent use.