Documentation
¶
Overview ¶
Package limiter provides local and distributed rate limiting based on the Token Bucket algorithm.
The primary entry point is the RateLimiter interface:
dec, err := limiter.Allow(ctx, id, limit)
The returned Decision contains whether the request is allowed, how many whole tokens remain, and timing hints for callers that want to set rate-limit headers (for example, Retry-After).
Overview ¶
This package implements a Token Bucket:
- Each identity has a "bucket" holding tokens.
- The bucket refills over time up to a maximum capacity (Burst).
- Each Allow call consumes 1 token when available.
Unlike fixed-window counters, token buckets naturally support bursts while still enforcing a long-term average rate.
Core Types ¶
Limit defines the policy:
- Rate: tokens earned per Period (for example, 10 per second or 60 per minute)
- Period: the time window Rate is measured over
- Burst: maximum number of tokens the bucket can hold (also the maximum immediate burst)
Identity defines "who" is being rate-limited. It is split into:
- Namespace: a logical grouping (for example, "user", "ip", "api_key")
- Key: the identifier within that namespace (for example, "user_123")
Backends ¶
The package provides two implementations with the same Allow API:
MemoryLimiter: an in-process limiter backed by a Go map. This is useful for unit tests, local development, and single-instance deployments. Because its state is local to the process, it does not enforce a global limit across multiple replicas.
RedisLimiter: a distributed limiter backed by Redis. It uses a Lua script to perform the read/compute/write cycle atomically, which makes it safe to use across many application instances while enforcing a single global budget per identity.
Recommendation: use RedisLimiter in production when you need a global limit, and MemoryLimiter in tests (as a fast, dependency-free stand-in).
Concurrency ¶
MemoryLimiter is safe for concurrent use by multiple goroutines (it uses a mutex to protect its internal map and per-identity state). RedisLimiter delegates concurrency safety to Redis and the go-redis client.
Context and Error Policy ¶
Allow accepts a context.Context. RedisLimiter passes this context through to Redis operations so callers can enforce deadlines and cancel work to avoid cascading failures during partial outages.
This package does not impose a "fail open" vs "fail closed" policy. If Redis is unavailable or the context expires, Allow returns a non-nil error and the caller decides whether to deny traffic (protect the backend) or allow traffic (maximize availability).
Decision Semantics ¶
Decision fields are intended to be directly consumable by application code:
- Allow reports whether the current request is permitted.
- Remaining is the number of whole tokens remaining after the decision is applied (floored to an int64).
- RetryAfter is 0 when allowed; when denied it is the approximate duration until a single token is expected to be available.
- ResetTime is the absolute timestamp corresponding to time.Now()+RetryAfter.
Usage ¶
For a runnable example using MemoryLimiter, see ExampleMemoryLimiter in example_test.go.
Storage Details ¶
MemoryLimiter stores state in a process-local map keyed by:
"{namespace}:{key}"
RedisLimiter stores state in Redis under keys prefixed with "limiter:" and uses a Redis hash with two fields:
- "tokens": current token balance (float)
- "last_refill": last update time as seconds since epoch (float)
Redis keys are set to expire to avoid leaking memory for identities that stop sending requests.
Limitations and Notes ¶
- MemoryLimiter does not evict old identities; for long-lived processes with high-cardinality keys you likely want RedisLimiter or a custom in-memory store with TTL/LRU eviction.
- RedisLimiter requires a reachable Redis instance and returns errors directly; callers must decide their availability vs protection tradeoff.
- This package currently models each Allow call as a cost of 1 token.
- RedisLimiter uses EVALSHA; if Redis is restarted and script cache is cleared, Allow may return a NOSCRIPT error until the script is reloaded (recreating the limiter via NewRedisLimiter will load it).
Configuration ¶
RedisLimiter is configured using the Functional Options pattern:
limiter, _ := NewRedisLimiter(client,
WithPrefix("myapp:rate:"),
WithTimeout(2*time.Second),
WithRecorder(myMetrics),
)
Supported options:
- WithPrefix(string): Sets the key prefix (default "limiter:").
- WithTimeout(time.Duration): Sets the context timeout for Redis operations (default 5s).
- WithRecorder(MetricsRecorder): Injects a custom metrics backend.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Identity ¶
Identity uniquely identifies the subject being rate-limited (for example, a user ID, an API key, or an IP address).
type Limit ¶
Limit defines a token-bucket policy.
Rate is measured as tokens per Period. Burst is the maximum token capacity of the bucket and controls how many requests can be allowed immediately.
type MemoryLimiter ¶
type MemoryLimiter struct {
// contains filtered or unexported fields
}
MemoryLimiter is an in-process token-bucket rate limiter.
It is safe for concurrent use by multiple goroutines, but its state is local to the process and is not shared across replicas. Use RedisLimiter when you need a single global limit across multiple instances.
Example ¶
l := NewMemoryLimiter()
limit := Limit{
Rate: 10,
Period: time.Second,
Burst: 10,
}
id := Identity{Namespace: "user", Key: "user_123"}
dec, err := l.Allow(context.Background(), id, limit)
if err != nil {
panic(err)
}
fmt.Println(dec.Allow)
Output: true
func NewMemoryLimiter ¶
func NewMemoryLimiter() *MemoryLimiter
NewMemoryLimiter constructs a MemoryLimiter with empty state.
type MetricsRecorder ¶
type MetricsRecorder interface {
// Add increments a counter (e.g., requests_total)
Add(name string, value float64, tags map[string]string)
// Observe records a value in a histogram/distribution (e.g., latency)
Observe(name string, value float64, tags map[string]string)
}
MetricsRecorder defines the interface for collecting telemetry. We abstract this so we aren't tied to Prometheus, Datadog, or any specific vendor.
type NoOpMetricsRecorder ¶
type NoOpMetricsRecorder struct{}
NoOpMetricsRecorder is a placeholder that does nothing. It ensures we never have to check 'if r.recorder != nil' in our hot path.
type Option ¶
type Option func(*limiterConfig)
Option configures a Redis-backed rate limiter.
func WithPrefix ¶
WithPrefix sets the Redis key prefix. Default is "limiter:".
func WithRecorder ¶
func WithRecorder(recorder MetricsRecorder) Option
WithRecorder sets the metrics recorder. Default is NoOpMetricsRecorder.
func WithTimeout ¶
WithTimeout sets the timeout for Redis operations during initialization. Default is 5s.
type PrometheusRecorder ¶
type PrometheusRecorder struct {
// contains filtered or unexported fields
}
PrometheusRecorder implements MetricsRecorder using Prometheus counters and histograms. Inject it via WithRecorder to expose per-namespace allow/deny counters and p99 admission latency on a /metrics endpoint.
func NewPrometheusRecorder ¶
func NewPrometheusRecorder(reg prometheus.Registerer) *PrometheusRecorder
NewPrometheusRecorder creates a PrometheusRecorder and registers its metrics with reg. Pass prometheus.DefaultRegisterer for the default global registry.
type RateLimiter ¶
type RateLimiter interface {
Allow(ctx context.Context, id Identity, limit Limit) (Decision, error)
}
RateLimiter performs token-bucket admission control.
type RedisLimiter ¶
type RedisLimiter struct {
// contains filtered or unexported fields
}
RedisLimiter is a distributed rate limiter backed by Redis.
It uses a Lua script to perform the token-bucket update atomically, which allows multiple application instances to enforce a single shared limit.
func NewRedisLimiter ¶
func NewRedisLimiter(client *redis.Client, opts ...Option) (*RedisLimiter, error)
NewRedisLimiter validates connectivity and loads the embedded Lua script into Redis (SCRIPT LOAD). The returned limiter is ready to use.
type SlidingWindowLimiter ¶
type SlidingWindowLimiter struct {
// contains filtered or unexported fields
}
SlidingWindowLimiter is a distributed rate limiter using a strict sliding window backed by a Redis sorted set. Each request is recorded with its timestamp as the score; expired entries are pruned on every call. Unlike a token bucket, this algorithm does not accumulate burst capacity — it enforces an exact count over a rolling window, making it well-suited for security-sensitive routes.
func NewSlidingWindowLimiter ¶
func NewSlidingWindowLimiter(client *redis.Client, opts ...Option) (*SlidingWindowLimiter, error)
NewSlidingWindowLimiter validates connectivity and loads the embedded Lua script into Redis (SCRIPT LOAD). The returned limiter is ready to use.
Accepts the same Option values as NewRedisLimiter (WithPrefix, WithTimeout, WithRecorder).
func (*SlidingWindowLimiter) Allow ¶
func (l *SlidingWindowLimiter) Allow(ctx context.Context, id Identity, limit Limit) (Decision, error)
Allow checks whether a request for the given identity should be allowed within the rolling window defined by limit.Period. The window holds at most limit.Rate requests; limit.Burst is not used. Each call has a fixed cost of 1.