ratelimit

package
v0.18.2 Latest Latest
Warning

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

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 8 Imported by: 3

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ClientIP

func ClientIP(r *http.Request) string

ClientIP returns client ip from request

Types

type ConcurrentQueueStrategy

type ConcurrentQueueStrategy struct {
	Capacity int // max concurrent at a time
	Size     int // queue size
	// contains filtered or unexported fields
}

ConcurrentQueueStrategy implements Strategy that allow only max concurrent requests at a time other requests will queue, until queue full then the request wil drop

func (*ConcurrentQueueStrategy) After

After always return 0, since we don't know when request will finish

func (*ConcurrentQueueStrategy) Put

func (b *ConcurrentQueueStrategy) Put(key string)

Put removes requests count

func (*ConcurrentQueueStrategy) Take

func (b *ConcurrentQueueStrategy) Take(key string) bool

Take returns true if current requests less than capacity

type ConcurrentStrategy

type ConcurrentStrategy struct {
	Capacity int // Max concurrent at a time
	// contains filtered or unexported fields
}

ConcurrentStrategy implements Strategy that allow only max concurrent requests at a time other requests will drop

func (*ConcurrentStrategy) After

After always return 0, since we don't know when request will finish

func (*ConcurrentStrategy) Put

func (b *ConcurrentStrategy) Put(key string)

Put removes requests count

func (*ConcurrentStrategy) Take

func (b *ConcurrentStrategy) Take(key string) bool

Take returns true if current requests less than capacity

type Event added in v0.18.0

type Event struct {
	// Name is the operator-set RateLimiter.Name (may be ""), so several rate limiters
	// can be told apart in metrics. It is bounded by construction (operators set it).
	Name string
	// Result is allowed or limited.
	Result Result
}

Event reports one rate-limit decision to RateLimiter.Observe. It carries only bounded fields: the operator-set limiter Name and the Result. It deliberately does NOT carry the client key, whose cardinality is unbounded and would blow up any per-key metric label set.

type ExceededHandler

type ExceededHandler func(w http.ResponseWriter, r *http.Request, after time.Duration)

ExceededHandler type

type FixedWindowStrategy

type FixedWindowStrategy struct {
	Max  int           // Max token per window
	Size time.Duration // Window size
	// contains filtered or unexported fields
}

FixedWindowStrategy implements Strategy using fixed window algorithm

func (*FixedWindowStrategy) After

func (b *FixedWindowStrategy) After(key string) time.Duration

After returns next time that can take again

func (*FixedWindowStrategy) Put

func (b *FixedWindowStrategy) Put(string)

Put does nothing

func (*FixedWindowStrategy) Take

func (b *FixedWindowStrategy) Take(key string) bool

Take takes a token from bucket, return true if token available to take

type LeakyBucketStrategy

type LeakyBucketStrategy struct {
	PerRequest time.Duration // time per request
	Size       int           // queue size
	// contains filtered or unexported fields
}

LeakyBucketStrategy implements Strategy using leaky bucket algorithm

func (*LeakyBucketStrategy) After

func (b *LeakyBucketStrategy) After(key string) time.Duration

After returns time that can take again

func (*LeakyBucketStrategy) Put

func (b *LeakyBucketStrategy) Put(string)

Put do nothing

func (*LeakyBucketStrategy) Take

func (b *LeakyBucketStrategy) Take(key string) bool

Take waits until token can be take, unless queue full will return false

type ObserveFunc added in v0.18.0

type ObserveFunc func(Event)

ObserveFunc is the rate-limit observation-hook shape, returned by prom.RateLimit for wiring into RateLimiter.Observe. It fires once per Take decision, IN ADDITION to (never replacing) ExceededHandler, on the request goroutine — keep it cheap.

type RateLimiter

type RateLimiter struct {
	Key             func(r *http.Request) string
	ExceededHandler ExceededHandler
	Strategy        Strategy

	// Observe, if set, is fired on EVERY Take decision with a bounded Event (the Name
	// below and allowed/limited). Unlike ExceededHandler it does NOT replace the
	// response — observability is independent of what the client sees, so merely
	// COUNTING rejections no longer means reimplementing the 429. It runs synchronously
	// on the request goroutine; keep it cheap. nil disables it. See prom.RateLimit.
	Observe ObserveFunc

	// Name is an operator-set, bounded label carried on every Event so several rate
	// limiters are distinguishable in metrics; "" is fine (the prom adapter maps it).
	// NEVER derived from the client key (unbounded cardinality).
	Name string

	ReleaseOnWriteHeader bool // release token when write response's header
	ReleaseOnHijacked    bool // release token when hijacked
}

RateLimiter middleware

Example (ExceededHandler)

Customize the over-limit response with ExceededHandler instead of the default plain-text 429.

package main

import (
	"net/http"
	"time"

	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	m := ratelimit.FixedWindowPerMinute(120)
	m.ExceededHandler = func(w http.ResponseWriter, r *http.Request, after time.Duration) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusTooManyRequests)
		_, _ = w.Write([]byte(`{"error":"rate limited"}`))
	}

	s := parapet.New()
	s.Use(m)
}

func Concurrent

func Concurrent(capacity int) *RateLimiter

Concurrent creates new concurrent rate limiter

Example

Concurrent caps the number of in-flight requests per key rather than the arrival rate; once a request finishes, its slot is freed. Excess requests are rejected immediately. Useful for protecting an expensive endpoint from pile-up.

package main

import (
	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.Concurrent(4)) // at most 4 in-flight requests per client IP
}

func ConcurrentQueue

func ConcurrentQueue(capacity, size int) *RateLimiter

ConcurrentQueue creates new concurrent queue rate limiter

Example

ConcurrentQueue is like Concurrent but holds excess requests in a bounded queue instead of rejecting them outright: up to Capacity run at once, up to Size wait, anything beyond that is dropped.

package main

import (
	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.ConcurrentQueue(4, 16)) // 4 concurrent, 16 queued per key
}

func FixedWindow

func FixedWindow(rate int, unit time.Duration) *RateLimiter

FixedWindow creates new fixed window rate limiter

Example

FixedWindow with an arbitrary window size when the per-second/minute/hour helpers don't fit — here, 100 requests every 5 minutes.

package main

import (
	"time"

	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.FixedWindow(100, 5*time.Minute))
}

func FixedWindowPerHour

func FixedWindowPerHour(rate int) *RateLimiter

FixedWindowPerHour creates new rate limiter per hour

func FixedWindowPerMinute

func FixedWindowPerMinute(rate int) *RateLimiter

FixedWindowPerMinute creates new rate limiter per minute

func FixedWindowPerSecond

func FixedWindowPerSecond(rate int) *RateLimiter

FixedWindowPerSecond creates new rate limiter per second

Example

Cap each client IP to a fixed number of requests per window. This is the most common setup: callers exceeding the budget get a 429 with a Retry-After header. The default Key is ClientIP, so no further configuration is needed.

package main

import (
	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.FixedWindowPerSecond(10)) // 10 req/s per client IP
	// s.Use(upstream.SingleHost("10.0.0.1:8080")) — the protected backend.
}

func LeakyBucket

func LeakyBucket(perRequest time.Duration, size int) *RateLimiter

LeakyBucket creates new leaky bucket rate limiter

Example

LeakyBucket smooths bursts by spacing requests out: it admits one request per PerRequest interval, buffering up to Size before dropping. Here, ~5 req/s with a 10-deep buffer.

package main

import (
	"time"

	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.LeakyBucket(200*time.Millisecond, 10))
}

func New

func New(strategy Strategy) *RateLimiter

New creates new rate limiter

Example

Rate-limit on something other than the client IP by setting Key — here, an API key header, so the limit is per credential regardless of source address.

package main

import (
	"net/http"
	"time"

	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	m := ratelimit.New(&ratelimit.FixedWindowStrategy{
		Max:  60,
		Size: time.Minute,
	})
	m.Key = func(r *http.Request) string {
		return r.Header.Get("X-API-Key")
	}

	s := parapet.New()
	s.Use(m)
}

func RedisFixedWindow added in v0.18.0

func RedisFixedWindow(runner RedisRunner, rate int, unit time.Duration) *RateLimiter

RedisFixedWindow creates a Redis-backed fixed-window rate limiter, so N proxy instances enforce one GLOBAL limit. runner is your injected Redis client adapter. It fails OPEN on a Redis error (availability over strict limiting); build the strategy directly to change that.

func RedisFixedWindowPerHour added in v0.18.0

func RedisFixedWindowPerHour(runner RedisRunner, rate int) *RateLimiter

RedisFixedWindowPerHour creates a Redis-backed fixed-window limiter per hour.

func RedisFixedWindowPerMinute added in v0.18.0

func RedisFixedWindowPerMinute(runner RedisRunner, rate int) *RateLimiter

RedisFixedWindowPerMinute creates a Redis-backed fixed-window limiter per minute.

Example

RedisFixedWindow enforces ONE global limit across a fleet of proxy instances by keeping the counter in Redis. Inject your client via a tiny RedisRunnerFunc adapter so pkg/ratelimit depends on no Redis library; it fails open on a Redis error. Here every instance shares a 1000 req/min budget per client IP.

package main

import (
	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	// rdb is your *redis.Client (go-redis); the adapter is the only client-specific code.
	// runner := ratelimit.RedisRunnerFunc(
	// 	func(ctx context.Context, script string, keys []string, args ...any) (int64, error) {
	// 		return rdb.Eval(ctx, script, keys, args...).Int64()
	// 	})
	var runner ratelimit.RedisRunner // = runner above

	s := parapet.New()
	s.Use(ratelimit.RedisFixedWindowPerMinute(runner, 1000))
}

func RedisFixedWindowPerSecond added in v0.18.0

func RedisFixedWindowPerSecond(runner RedisRunner, rate int) *RateLimiter

RedisFixedWindowPerSecond creates a Redis-backed fixed-window limiter per second.

func SlidingWindow added in v0.18.0

func SlidingWindow(rate int, unit time.Duration) *RateLimiter

SlidingWindow creates new sliding window rate limiter.

func SlidingWindowPerHour added in v0.18.0

func SlidingWindowPerHour(rate int) *RateLimiter

SlidingWindowPerHour creates new sliding window rate limiter per hour.

func SlidingWindowPerMinute added in v0.18.0

func SlidingWindowPerMinute(rate int) *RateLimiter

SlidingWindowPerMinute creates new sliding window rate limiter per minute.

func SlidingWindowPerSecond added in v0.18.0

func SlidingWindowPerSecond(rate int) *RateLimiter

SlidingWindowPerSecond creates new sliding window rate limiter per second.

Example

SlidingWindow smooths the up-to-2x burst a fixed window admits across its boundary, at O(1) memory per key — a time-weighted blend of the current and previous window counts. Same constructors and 429 behavior as FixedWindow.

package main

import (
	"github.com/moonrhythm/parapet"
	"github.com/moonrhythm/parapet/pkg/ratelimit"
)

func main() {
	s := parapet.New()
	s.Use(ratelimit.SlidingWindowPerSecond(10)) // ~10 req/s per client IP, no boundary doubling
}

func (RateLimiter) ServeHandler

func (m RateLimiter) ServeHandler(h http.Handler) http.Handler

ServeHandler implements middleware interface

type RedisFixedWindowStrategy added in v0.18.0

type RedisFixedWindowStrategy struct {

	// Runner is the REQUIRED injected Redis client adapter. A nil Runner makes every
	// Take fail per FailOpen (no panic on the hot path).
	Runner RedisRunner

	// Max is the global tokens per window. Max <= 0 admits nothing.
	Max int

	// Size is the window size; <= 0 resolves to time.Second. Windows that do not
	// divide an hour are fine here (After is epoch-anchored, see below).
	Size time.Duration

	// Prefix namespaces the Redis keys; "" resolves to "parapet:rl:".
	Prefix string

	// Timeout bounds each Take's Redis round-trip (a per-call context deadline); <= 0
	// resolves to 100ms. It bounds a SLOW (not just failed) Redis off the hot path.
	Timeout time.Duration

	// FailOpen decides a Redis error: true admits (the constructors' default), false
	// denies (the zero value). Fail open trades strict limiting for availability.
	FailOpen bool

	// OnError observes a Redis error (timeout, dial, script error); nil ignores it.
	OnError func(error)
	// contains filtered or unexported fields
}

RedisFixedWindowStrategy implements Strategy with a Redis-backed fixed window, so a fleet of proxy instances shares one global counter per key. The hot path is one atomic Lua round-trip; all mutable state lives in Redis (no local map, no mutex).

Config is read once on first use; set fields before serving. The zero value fails CLOSED on a Redis error (deny); the constructors set FailOpen = true (fail open for availability). Like the in-memory FixedWindowStrategy it admits up to 2*Max across a window boundary, ignores Weight, and has no background goroutine.

func (*RedisFixedWindowStrategy) After added in v0.18.0

After returns the time until the current window rolls over, with NO round-trip. It is anchored to the SAME integer epoch (now/Size) folded into the Redis key, so the returned duration is exactly the time to the end of the current window's key.

This is the same epoch grid FixedWindowStrategy.{Take,After} use, and it is correct for ANY Size (unlike time.Truncate's year-1 anchoring, which only matches for sizes that divide the year1->epoch offset). The middleware only calls After after a rejecting Take, so it always reflects a window the key is currently in.

func (*RedisFixedWindowStrategy) Put added in v0.18.0

Put is a no-op: a fixed window does not return tokens (matches FixedWindow).

func (*RedisFixedWindowStrategy) Take added in v0.18.0

func (b *RedisFixedWindowStrategy) Take(key string) bool

Take atomically increments the current window's Redis counter and reports whether the caller is within Max. Exactly one round-trip; the over-Max request is still counted (the safe direction — never over-admits). On any Redis error (including a Timeout) it fails open or closed per FailOpen.

type RedisRunner added in v0.18.0

type RedisRunner interface {
	Eval(ctx context.Context, script string, keys []string, args ...any) (int64, error)
}

RedisRunner is the minimal Redis surface the distributed limiter needs: run a Lua script and return its integer reply. Inject an adapter over your client (go-redis, rueidis, redigo, …) so pkg/ratelimit hard-depends on no Redis client. keys/args map to the script's KEYS/ARGV. Implementations MUST be safe for concurrent use and MUST return either the script's integer reply or a non-nil error (a broken adapter that returns (0, nil) would be read as "1 request seen" and admit).

type RedisRunnerFunc added in v0.18.0

type RedisRunnerFunc func(ctx context.Context, script string, keys []string, args ...any) (int64, error)

RedisRunnerFunc adapts a plain func to RedisRunner.

func (RedisRunnerFunc) Eval added in v0.18.0

func (f RedisRunnerFunc) Eval(ctx context.Context, script string, keys []string, args ...any) (int64, error)

Eval implements RedisRunner.

type Result added in v0.18.0

type Result uint8

Result classifies the outcome of one rate-limit decision, reported via RateLimiter.Observe.

const (
	// ResultAllowed: the request was admitted (Strategy.Take returned true). Under a
	// Redis fail-open this includes requests admitted because Redis was unreachable —
	// wire RedisFixedWindowStrategy.OnError (prom.RateLimitRedisError) to tell those
	// apart from genuinely-under-limit admits.
	ResultAllowed Result = iota
	// ResultLimited: the request was rejected (Strategy.Take returned false); the
	// ExceededHandler ran (a 429 by default).
	ResultLimited
)

func (Result) String added in v0.18.0

func (r Result) String() string

String renders a Result as a stable, bounded metric-label value.

type SlidingWindowStrategy added in v0.18.0

type SlidingWindowStrategy struct {
	Max  int           // Max token per window; Max <= 0 admits nothing
	Size time.Duration // Window size (the trailing interval the limit applies over)
	// contains filtered or unexported fields
}

SlidingWindowStrategy implements Strategy using the sliding-window-counter algorithm: the effective count is a time-weighted blend of the current fixed window's count and the previous window's count, the previous count fading out linearly as the current window elapses. This smooths the up-to-2x burst a plain fixed window admits across its boundary, while keeping O(1) memory per key (two counters) — unlike an exact sliding log, whose per-key memory is attacker-controlled (O(admitted requests), a DoS-amplification footgun at the edge).

It is an APPROXIMATION, not exact: the blend assumes the previous window's requests were uniformly distributed in time. Back-loaded traffic (a burst at the tail of a window) can briefly over-admit and front-loaded traffic can briefly over-throttle, both bounded and typically under ~1% of Max. Use it when you want to remove the fixed window's boundary doubling cheaply; reach for an exact log only if a hard per-window guarantee is worth the unbounded per-key memory.

Per-KEY state is O(1), but the working set (live map entries) is O(distinct keys seen in the last ~2 windows): there is no max-entries cap, so a unique-key flood inflates memory until the background sweep reclaims it — unlike FixedWindow, which clears its whole map each window boundary (a counter cannot, since the previous window's count is load-bearing for the blend). Like the other limiters, window indices ride the wall clock: a non-monotonic step that re-crosses a boundary can shift at most one window's budget (bounded by Max, never FixedWindow's full reset).

func (*SlidingWindowStrategy) After added in v0.18.0

func (b *SlidingWindowStrategy) After(key string) time.Duration

After returns how long until the next request for key would be admitted. It never mutates shared state: it reads the item under RLock and computes on locals.

It may return 0 even immediately after Take returned false for the same key, if a window boundary falls between the two calls (the read-only roll then shifts curr->prev, freeing budget) — the client genuinely can take now, so do not treat "After > 0 while blocked" as an invariant.

func (*SlidingWindowStrategy) Put added in v0.18.0

func (b *SlidingWindowStrategy) Put(string)

Put does nothing — this is an arrival-rate limiter, not a concurrency limiter.

func (*SlidingWindowStrategy) Take added in v0.18.0

func (b *SlidingWindowStrategy) Take(key string) bool

Take admits a request iff the weighted trailing-window count stays within Max, so a steady stream is capped at APPROXIMATELY Max over any trailing window (exactly Max for uniform/front-loaded traffic; see the type doc for the boundary approximation). Max <= 0 admits nothing.

type Strategy

type Strategy interface {
	// Take returns true if success
	Take(key string) bool

	// Put calls after finished
	Put(key string)

	// After returns next time that can take again
	After(key string) time.Duration
}

Strategy interface

Jump to

Keyboard shortcuts

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