Skip to main content
Version: Next (unreleased)

Redis

An in-memory limiter only knows about the requests its own process has seen. Run three instances behind a load balancer and a client gets three times the limit. The redisstore module keeps the state in Redis instead, so every instance enforces one shared limit.

go get github.com/KARTIKrocks/go-ratelimit/redisstore

redisstore v1.0.0 requires the core module at v1.1.0 or later.

Quick start​

import (
"github.com/redis/go-redis/v9"

"github.com/KARTIKrocks/go-ratelimit"
"github.com/KARTIKrocks/go-ratelimit/redisstore"
)

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store := redisstore.NewRedisClientAdapter(client)

limiter := redisstore.NewRedisTokenBucket(store, "myapp", 10, 20) // 10/s, burst 20

handler := ratelimit.Middleware(limiter)(mux)

The Redis limiters implement KeyedLimiter and KeyedResultLimiter, so they plug into the middleware, PathLimiter, and the metrics wrapper exactly like the in-memory ones.

Limiters​

ConstructorAlgorithm
NewRedisTokenBucket(client, prefix, rate, burst, opts...)token bucket
NewRedisTokenBucketPerDuration(client, prefix, count, per, burst, opts...)token bucket
NewRedisSlidingWindow(client, prefix, limit, window, opts...)sliding window counter
NewRedisFixedWindow(client, prefix, limit, window, opts...)fixed window

Windows have millisecond precision: they are rounded down to whole milliseconds and must be at least 1ms.

How it works​

Each decision is one Lua script, run atomically by Redis:

  • Redis server time. Scripts read the clock with Redis TIME, so clock drift between your application servers cannot skew the limit.
  • One Redis key per rate-limit key, stored as a small hash with an expiry, so idle keys clean themselves up and no cleanup goroutine is needed.
  • Redis Cluster safe. Since a script touches a single key, it never spans hash slots.

Keys are named <prefix>:<algorithm>:{<key>}, where the algorithm is tb, sw, or fw. Use a distinct prefix per limiter so two limits never share state:

myapp:tb:{user:42}
myapp:fw:{203.0.113.9}

Redis Cluster​

Wrap a cluster client with the cluster adapter; nothing else changes:

cluster := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{"redis-0:6379", "redis-1:6379", "redis-2:6379"},
})
store := redisstore.NewRedisClusterClientAdapter(cluster)

When Redis fails​

A rate limiter on the request path should not take your service down with it. By default the limiters fail open: if Redis returns an error, the request is allowed.

limiter := redisstore.NewRedisTokenBucket(store, "myapp", 10, 20,
redisstore.WithFailClosed(),
redisstore.WithErrorHandler(func(err error) {
slog.Error("rate limiter", "err", err)
metrics.RecordError("api")
}),
)
OptionEffect
WithFailClosed()deny on a Redis error; the denied Result carries a one-second RetryAfter, and WaitN returns the error
WithErrorHandler(fn)call fn with every Redis error, for logging or metrics; not called when the error is the caller's context ending

Fail open for general API traffic, where an outage of the limiter should not be an outage of the API. Fail closed for limits that guard against abuse or cost — login attempts, SMS sends, paid upstream APIs.

Always set an error handler. Failing open silently means you will not notice that rate limiting has stopped working.

Context and timeouts​

The interface methods use context.Background(). For control over how long a check may wait on Redis, use the context variants:

ctx, cancel := context.WithTimeout(r.Context(), 50*time.Millisecond)
defer cancel()

result := limiter.TakeNCtx(ctx, key, 1)
MethodContext variant
Take, TakeNTakeNCtx(ctx, key, n)
Check, CheckNCheckNCtx(ctx, key, n)
ResetResetCtx(ctx, key)
ResetAllResetAllCtx(ctx)
Wait, WaitNalready take a context

Also set sensible DialTimeout, ReadTimeout, and WriteTimeout on the go-redis client itself.

Resetting​

Reset(key) deletes one key. ResetAll() deletes every key under the limiter's prefix by scanning for it with SCAN and removing matches in batches, on every master in a cluster. On a large keyspace that takes a while; it is meant for tests and administrative tools, not the request path.

Custom clients​

The limiters accept any RedisClient, an interface covering Eval, Get, Set, Del, Incr, Expire, and TTL. Implement it to use a different Redis library or to add instrumentation. To support ResetAll, also implement KeyDeleter; without it, ResetAll deletes nothing and passes ErrResetAllUnsupported to the error handler — silently, if none is set.

Eval must return the script's reply as a []any of int64, as go-redis does. int and numeric strings are also accepted, but only on 64-bit platforms: the reply carries Unix-millisecond times that overflow a 32-bit int.