ratelimit

package module
v0.4.7 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: MIT Imports: 11 Imported by: 0

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

View Source
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

func KeyByIP(r *http.Request) string

KeyByIP returns the canonical client IP from r.RemoteAddr (port stripped).

func KeyByRealIP added in v0.4.4

func KeyByRealIP(r *http.Request) string

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

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

LimitAll limits every request through a single shared counter, regardless of client. Equivalent to httprate.LimitAll.

func LimitByIP added in v0.4.4

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

LimitByIP limits requests per client IP, taken from r.RemoteAddr. Equivalent to httprate.LimitByIP.

func LimitByRealIP added in v0.4.4

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

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

func Middleware(cfg Config) func(http.Handler) http.Handler

Middleware returns the http middleware enforcing cfg. Per-request flow:

  1. KeyFunc(r) → if empty, pass through unmodified.
  2. 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.
  3. Sleep for the computed backoff (if > 0).
  4. Invoke next with a status-capturing ResponseWriter.
  5. 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

func NewCacheStore(c *cache.Cache[string, *Bucket]) Store

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

func NewMemoryStore(capacity int) (Store, error)

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.

Jump to

Keyboard shortcuts

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