Documentation
¶
Overview ¶
Package ratelimit provides distributed rate limiters backed by Redis, plus net/http middleware to put them in front of an API.
Three algorithms share one Limiter interface:
- TokenBucket: allows bursts up to a capacity and a steady refill rate.
- SlidingWindowLog: exact "at most N requests in any window", with memory proportional to N per key.
- SlidingWindowCounter: approximate sliding window in constant memory.
Each decision runs as a single Lua script, so it is atomic and costs one round trip, and time comes from the Redis server clock, so application instances with skewed clocks still agree. Any number of processes sharing one Redis enforce one combined limit per key.
Basic use:
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{
Capacity: 20, // burst
Rate: 10, // tokens per Period
Period: time.Second,
})
if err != nil {
log.Fatal(err)
}
res, err := limiter.Allow(ctx, "user:42")
if err != nil {
// Redis is unavailable: decide whether to fail open or closed.
}
if !res.Allowed {
// Reject, and tell the client to come back after res.RetryAfter.
}
Or as HTTP middleware:
mux.Handle("/api/", ratelimit.Middleware(limiter)(apiHandler))
Requires Redis 5 or newer (or a compatible server such as Valkey). Redis Cluster is supported: each call touches a single key.
Index ¶
- Constants
- Variables
- func KeyByIP(r *http.Request) (string, error)
- func Middleware(l Limiter, opts ...MiddlewareOption) func(http.Handler) http.Handler
- type Client
- type KeyFunc
- type Limiter
- type MiddlewareOption
- type Option
- type Result
- type SlidingWindowConfig
- type SlidingWindowCounter
- type SlidingWindowLog
- type TokenBucket
- type TokenBucketConfig
Examples ¶
Constants ¶
const DefaultPrefix = "ratelimit"
DefaultPrefix is the Redis key prefix used unless WithPrefix is given.
Variables ¶
var ( // ErrInvalidConfig is returned by the constructors for an unusable // configuration. The returned error wraps it with details. ErrInvalidConfig = errors.New("ratelimit: invalid config") // ErrInvalidN is returned by AllowN when n is below 1 or above the // configured limit, since such a request could never be allowed. ErrInvalidN = errors.New("ratelimit: n must be between 1 and the limit") )
var ErrNoKey = errors.New("ratelimit: no rate limit key in request")
ErrNoKey is returned by a KeyFunc when a request has no rate limit key.
Functions ¶
func KeyByIP ¶
KeyByIP keys requests by the client IP taken from r.RemoteAddr.
IPv6 addresses are grouped by their /64 prefix, because a single client is usually handed a whole /64 and could otherwise rotate addresses to dodge the limit.
RemoteAddr is the address of the direct peer. Behind a reverse proxy or load balancer that is the proxy itself, so first rewrite RemoteAddr from a header the proxy sets and clients cannot spoof, or use a custom KeyFunc. Never trust X-Forwarded-For from arbitrary clients.
func Middleware ¶
Middleware returns net/http middleware that applies l to every request.
Every response carries these headers:
X-RateLimit-Limit the configured limit X-RateLimit-Remaining requests left right now X-RateLimit-Reset seconds until the limit is fully restored
Limited requests also get Retry-After (seconds) and, by default, 429 Too Many Requests. Requests whose key cannot be extracted get 400 Bad Request. Limiter failures get 503 unless WithFailOpen is set.
Example ¶
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/redis/go-redis/v9"
"github.com/RanggaEghaPermana/ratelimit"
)
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{Capacity: 20, Rate: 10})
if err != nil {
log.Fatal(err)
}
api := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "hello")
})
mux := http.NewServeMux()
mux.Handle("/api/", ratelimit.Middleware(limiter,
ratelimit.WithKeyFunc(ratelimit.KeyByHeader("X-API-Key")),
ratelimit.WithFailOpen(),
ratelimit.WithErrorHook(func(r *http.Request, err error) {
log.Printf("rate limit %s %s: %v", r.Method, r.URL.Path, err)
}),
)(api))
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}
Output:
Types ¶
type Client ¶
Client is the subset of the go-redis API the limiters need. *redis.Client, *redis.ClusterClient, *redis.Ring and redis.UniversalClient all satisfy it.
type KeyFunc ¶
KeyFunc extracts the rate limit key from a request. Return ErrNoKey (or an error wrapping it) when the request carries no usable key.
func KeyByHeader ¶
KeyByHeader keys requests by the value of the named header, such as an API key. Requests without the header get ErrNoKey and are rejected, so a client cannot escape the limit by omitting it.
type Limiter ¶
type Limiter interface {
// Allow is shorthand for AllowN(ctx, key, 1).
Allow(ctx context.Context, key string) (Result, error)
// AllowN reports whether n requests for key may proceed now, and records
// them if so. A denied call records nothing. n must be between 1 and the
// configured limit, otherwise ErrInvalidN is returned.
AllowN(ctx context.Context, key string, n int) (Result, error)
// Reset forgets all state for key, restoring its full limit.
Reset(ctx context.Context, key string) error
}
Limiter decides whether a request identified by a key may proceed.
All implementations in this package are safe for concurrent use, and the decision is atomic in Redis: any number of processes sharing the same Redis and prefix enforce one combined limit per key.
type MiddlewareOption ¶
type MiddlewareOption func(*middlewareConfig)
MiddlewareOption configures Middleware.
func WithErrorHook ¶
func WithErrorHook(fn func(r *http.Request, err error)) MiddlewareOption
WithErrorHook registers a function called whenever a key cannot be extracted or the limiter fails, typically to log or count the error. It is called before the response is written and must not write one itself.
func WithFailOpen ¶
func WithFailOpen() MiddlewareOption
WithFailOpen lets requests through when the limiter itself fails, for example because Redis is unreachable. By default such requests get 503 Service Unavailable.
Failing open keeps your API up when Redis is down, at the cost of not limiting during the outage. Keep the default for endpoints where the limit is a security control, such as login.
func WithKeyFunc ¶
func WithKeyFunc(fn KeyFunc) MiddlewareOption
WithKeyFunc sets how requests are keyed. The default is KeyByIP.
func WithLimitedHandler ¶
func WithLimitedHandler(h http.Handler) MiddlewareOption
WithLimitedHandler sets the handler that responds to limited requests. The rate limit headers, including Retry-After, are already set when it runs, and it is responsible for writing the status code. The default replies 429 Too Many Requests with a plain text body.
type Option ¶
type Option func(*options)
Option configures a limiter.
func WithPrefix ¶
WithPrefix sets the Redis key prefix. Give each limiter its own prefix when several limiters share one Redis, for example "login" and "api"; limiters with the same prefix and algorithm share their counters.
type Result ¶
type Result struct {
// Allowed reports whether the request may proceed.
Allowed bool
// Limit is the configured maximum: the bucket capacity for a token
// bucket, or the per-window limit for a sliding window.
Limit int
// Remaining is how many more requests would be allowed right now.
Remaining int
// RetryAfter is how long to wait before the same request would be
// allowed. It is zero when Allowed is true.
RetryAfter time.Duration
// ResetAfter is how long until the key is back to its full limit if no
// further requests arrive.
ResetAfter time.Duration
}
Result describes the outcome of a single AllowN call.
type SlidingWindowConfig ¶
type SlidingWindowConfig struct {
// Limit is the maximum number of requests within any Window.
Limit int
// Window is the length of the rolling window. Must be at least 1ms.
Window time.Duration
}
SlidingWindowConfig configures both sliding window limiters.
type SlidingWindowCounter ¶
type SlidingWindowCounter struct {
// contains filtered or unexported fields
}
SlidingWindowCounter is an approximate sliding window Limiter using constant memory per key.
It counts requests in the current and previous fixed windows and weights the previous count by how much of it still overlaps the sliding window. This assumes the previous window's traffic was evenly spread, so the effective limit can be off by a small fraction when traffic is bursty.
func NewSlidingWindowCounter ¶
func NewSlidingWindowCounter(client Client, cfg SlidingWindowConfig, opts ...Option) (*SlidingWindowCounter, error)
NewSlidingWindowCounter returns an approximate sliding window limiter backed by client.
Example ¶
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"github.com/RanggaEghaPermana/ratelimit"
)
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// 10,000 requests per hour per API key, in constant memory per key.
limiter, err := ratelimit.NewSlidingWindowCounter(rdb, ratelimit.SlidingWindowConfig{
Limit: 10_000,
Window: time.Hour,
}, ratelimit.WithPrefix("api-hourly"))
if err != nil {
log.Fatal(err)
}
// A batch endpoint can charge several requests at once.
res, err := limiter.AllowN(context.Background(), "key:abc123", 50)
if err == nil && res.Allowed {
fmt.Println("batch accepted")
}
}
Output:
type SlidingWindowLog ¶
type SlidingWindowLog struct {
// contains filtered or unexported fields
}
SlidingWindowLog is an exact sliding window Limiter: it allows a request only if fewer than Limit requests were accepted in the preceding Window.
It stores one sorted-set entry per accepted request, so memory per key grows with Limit. Prefer SlidingWindowCounter for large limits.
func NewSlidingWindowLog ¶
func NewSlidingWindowLog(client Client, cfg SlidingWindowConfig, opts ...Option) (*SlidingWindowLog, error)
NewSlidingWindowLog returns an exact sliding window limiter backed by client.
Example ¶
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"github.com/RanggaEghaPermana/ratelimit"
)
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// At most 5 login attempts in any 15 minutes, counted exactly.
logins, err := ratelimit.NewSlidingWindowLog(rdb, ratelimit.SlidingWindowConfig{
Limit: 5,
Window: 15 * time.Minute,
}, ratelimit.WithPrefix("login"))
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
key := "user:alice"
res, err := logins.Allow(ctx, key)
if err != nil || !res.Allowed {
fmt.Println("too many login attempts")
return
}
// On a successful login, forget the failed attempts.
_ = logins.Reset(ctx, key)
}
Output:
type TokenBucket ¶
type TokenBucket struct {
// contains filtered or unexported fields
}
TokenBucket is a Limiter that allows bursts of up to Capacity requests and a sustained average of Rate requests per Period.
Each key starts with a full bucket. Every request takes a token, and tokens flow back continuously (fractions included) at Rate per Period, never exceeding Capacity.
func NewTokenBucket ¶
func NewTokenBucket(client Client, cfg TokenBucketConfig, opts ...Option) (*TokenBucket, error)
NewTokenBucket returns a token bucket limiter backed by client.
Example ¶
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"github.com/RanggaEghaPermana/ratelimit"
)
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// Bursts of up to 20 requests, then a steady 10 per second.
limiter, err := ratelimit.NewTokenBucket(rdb, ratelimit.TokenBucketConfig{
Capacity: 20,
Rate: 10,
Period: time.Second,
})
if err != nil {
log.Fatal(err)
}
res, err := limiter.Allow(context.Background(), "user:42")
if err != nil {
log.Printf("rate limiter unavailable: %v", err)
return
}
if !res.Allowed {
fmt.Printf("slow down, retry in %v\n", res.RetryAfter)
return
}
fmt.Printf("ok, %d requests left\n", res.Remaining)
}
Output:
type TokenBucketConfig ¶
type TokenBucketConfig struct {
// Capacity is the bucket size: the largest burst allowed at once.
Capacity int
// Rate is how many tokens are added back every Period.
Rate int
// Period is the refill interval for Rate. Defaults to one second.
Period time.Duration
}
TokenBucketConfig configures a TokenBucket.