ratelimit

package
v0.18.0 Latest Latest
Warning

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

Go to latest
Published: Jun 10, 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 intentionally DIVERGES from FixedWindowStrategy.After, which uses time.Truncate (anchored to time.Time's year-1 zero) and is therefore only correct for windows that divide an hour; epoch anchoring is correct for ANY Size. 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