Documentation
¶
Overview ¶
Package redisstore implements ratelimit.Store on top of Redis, so that every instance of an application enforces one shared limit.
Why a separate module ¶
Go resolves dependencies per module, not per package. If this lived in the core module, github.com/redis/go-redis/v9 would appear in the go.sum of every consumer — and in their SBOM, their Dependabot alerts and their govulncheck output — even for someone who only imports secureheaders. Keeping it in its own module makes the core's zero-dependency claim literally true and machine-verifiable.
The dependency arrow points one way: this module imports the core, and the core never imports this one.
Why the decision runs inside Redis ¶
The bucket update is a read-modify-write. Performed from Go it takes two round trips, and between them another request on the same key reads the same pre-decrement state. Under concurrency every one of those requests sees a token available and every one is admitted, so the effective limit becomes "burst per round trip" rather than "burst" — and opening several connections at once is precisely what an abusive client does.
The whole decision therefore runs as one Lua script, in one round trip, with Redis's single-threaded execution providing the atomicity. The script lives in token_bucket.lua as a standalone file rather than a Go string, so that it can be linted, read with syntax highlighting, and run directly against a server with redis-cli --eval while debugging.
Scripts are sent with EVALSHA and fall back to EVAL when the server reports NOSCRIPT, which happens after a restart or a SCRIPT FLUSH. That keeps the steady state to a hash on the wire without a cold start failing.
Failure behavior ¶
Every failure — a connection error, a timeout, a malformed reply — is returned as an error. This store never invents an "allowed" result to paper over an outage. What happens next is the middleware's decision, made through ratelimit.WithFailureMode, which defaults to rejecting; without an error here, that choice could not exist and an outage would look exactly like ordinary throttling.
Expiry ¶
Each bucket carries a TTL, so Redis reclaims idle buckets and no janitor goroutine is needed on the application side. The TTL is never shorter than the time the bucket takes to refill completely: dropping a partially refilled bucket would hand its client a fresh allowance, which is a rate-limit bypass rather than a cache miss.
Redis must be configured with maxmemory-policy noeviction ¶
That TTL reasoning is enforceable only as far as Redis lets it be. A server running under maxmemory with any eviction policy discards keys to reclaim memory whenever it is under pressure, and it does not know that these particular keys are rate-limit state — so it will drop a bucket that has not refilled, which is exactly the allowance grant the TTL floor exists to prevent.
Worse, it drops the wrong one. Recency is anti-correlated with safety here: a client that has just exhausted its burst cannot make requests, so its bucket stops being touched and becomes the *best* eviction candidate, while the bucket that has actually refilled — free to drop — was touched recently and is spared. The eviction lands on the one client whose limit it resets.
noeviction is the only safe setting, and the volatile-* family is not the narrower alternative it looks like. Every bucket carries a TTL, so "volatile" excludes nothing here; in a Redis shared with a cache whose entries do not all expire, it makes the rate-limit buckets the *preferred* victims. volatile-ttl is not an escape either: bucket TTLs are all refreshed to the same value on every call, so "nearest expiry" is "longest idle" restated — the same recency ordering, with the same anti-correlation.
Under noeviction, Redis rejects writes at the memory ceiling rather than silently discarding limits. This store then returns an error and ratelimit.WithFailureMode decides what happens, which is a visible outage instead of an invisible bypass — the trade this library makes everywhere else too. Give rate-limit state an instance or logical database that is not shared with a general-purpose cache; memory stays bounded by the TTLs regardless, since every bucket carries one.
New checks this at construction where the server will answer, and returns ErrUnsafeEvictionPolicy if it can see that eviction is on. The check is best-effort — managed Redis usually disables CONFIG — so it closes the case of an operator who never knew, without being something to rely on. This mirrors the choice ratelimit.MemoryStore makes in-process, where the same reasoning ruled out an LRU cap.
Threat model notes ¶
- Redis is trusted infrastructure here. Anyone who can write to it can set any client's bucket to full. Put it on a private network, require authentication, and use TLS.
- Bucket keys are derived from the ratelimit.KeyFunc, so they can contain client-controlled data. They are namespaced with a prefix and used only as key names, never interpolated into the script, so a crafted key cannot change what the script does.
- Clock skew between application instances shifts refill slightly. The script credits nothing for negative elapsed time, so skew errs toward over-restricting rather than toward free allowance.
Index ¶
Examples ¶
Constants ¶
const ( // DefaultKeyPrefix namespaces bucket keys so that they are recognizable in // a shared Redis and cannot collide with other data. DefaultKeyPrefix = "moat:ratelimit:" // DefaultIdleTTL is the floor for how long an untouched bucket is kept. The // effective TTL is never shorter than the bucket's full refill time. DefaultIdleTTL = 10 * time.Minute // DefaultTimeout bounds a single Redis round trip when the caller's context // has no earlier deadline. Without a bound, a hung backend turns a rate // limiter into a request-stalling denial of service — the limiter would be // the outage. DefaultTimeout = 250 * time.Millisecond )
Defaults for a Store.
Variables ¶
var ErrUnexpectedReply = errors.New("redisstore: unexpected reply from token bucket script")
ErrUnexpectedReply reports a reply from the script that does not have the documented shape. It indicates a version mismatch or a tampered script, and is deliberately an error rather than a best-effort interpretation.
var ErrUnsafeEvictionPolicy = errors.New("redisstore: Redis is configured to evict keys under memory pressure, which silently resets rate limits; set maxmemory-policy to noeviction, or pass redisstore.InsecureAllowKeyEviction if that is genuinely intended")
ErrUnsafeEvictionPolicy reports that the Redis server is configured to evict keys under memory pressure, which silently defeats this store's limit.
Why this is not merely a tuning preference ¶
Every bucket carries a TTL chosen so that a bucket is never dropped before it would have refilled completely — dropping a partially refilled bucket hands its client a fresh allowance, which is a bypass rather than a cache miss. A server running under maxmemory with an eviction policy discards keys whenever it is short of memory, and it has no idea these particular keys are rate-limit state. The TTL reasoning is then decorative.
Worse, the policies evict in the wrong order. Redis evicts by approximate idle time (the lru policies) or by nearest expiry (volatile-ttl); because every bucket's TTL is refreshed to the same value on every call, nearest expiry is idle time restated, so both come to the same ordering. And idle time is anti-correlated with safety here: a client that has just exhausted its burst cannot make requests, so its bucket stops being touched and becomes the most attractive eviction candidate — while the bucket that has actually refilled, and could be dropped for free, was touched recently and is spared. The eviction therefore lands on the one client whose limit it resets. This is the same failure ratelimit.MemoryStore fixed in-process by ordering on refill level instead of recency; here the ordering belongs to Redis and cannot be changed from Go.
Only noeviction is safe ¶
Every volatile-* policy targets keys that have a TTL, and every bucket has one, so "volatile" is not a narrower blast radius — in a Redis shared with a cache whose entries do not all expire, it makes the rate-limit buckets the *preferred* victims. volatile-ttl is not an escape either, for the reason above. That leaves noeviction, under which Redis rejects writes at the memory ceiling instead of silently discarding limits; the store then returns an error and ratelimit.WithFailureMode decides, which is a visible outage rather than an invisible bypass.
If eviction is genuinely acceptable for your deployment, say so with InsecureAllowKeyEviction.
Functions ¶
This section is empty.
Types ¶
type EvictionCheck ¶ added in v0.2.2
type EvictionCheck int
EvictionCheck reports what New was able to establish about the server's eviction configuration.
The eviction check is best-effort by design (see Store.EvictionCheck), and every reason it can come back unestablished is a reason that is *more* likely on managed Redis than on a local container. This type exists so that "the store checks this for you" can be turned into a question with an answer, rather than an assumption.
const ( // EvictionCheckUnknown is the zero value, and asserts nothing. A Store // returned by [New] never carries it; a zero-value Store is not usable. EvictionCheckUnknown EvictionCheck = iota // EvictionCheckVerified means every server reached reported a configuration // under which it will not evict: either no memory ceiling, or // maxmemory-policy noeviction. // // It is a statement about the moment the Store was built, not a standing // guarantee. See [Store.EvictionCheck]. EvictionCheckVerified // EvictionCheckNoConfigSupport means the client value passed to [New] does // not expose CONFIG GET at all, so nothing was asked. A test double or a // custom redis.Scripter implementation lands here. EvictionCheckNoConfigSupport // EvictionCheckReplyNotUnderstood means the server answered, but the reply // did not carry a usable maxmemory or maxmemory-policy value. EvictionCheckReplyNotUnderstood // EvictionCheckServerSilent means CONFIG GET returned an error: the command // is disabled or renamed — the normal case on ElastiCache, MemoryDB, Upstash // and Azure Cache — or the server was not reachable when [New] ran. // // This is the common outcome in production and the rare one in local // development, which is the inversion worth knowing about: the check is // loudest where the stakes are lowest. EvictionCheckServerSilent // EvictionCheckOptedOut means [InsecureAllowKeyEviction] was passed, so no // check was attempted and no round trip was made. EvictionCheckOptedOut )
func (EvictionCheck) String ¶ added in v0.2.2
func (c EvictionCheck) String() string
String implements fmt.Stringer.
func (EvictionCheck) Verified ¶ added in v0.2.2
func (c EvictionCheck) Verified() bool
Verified reports whether the check positively established a safe configuration. Every other value means the check was skipped, not that it passed.
type Option ¶
type Option func(*Store)
Option configures a Store.
func InsecureAllowKeyEviction ¶ added in v0.2.0
func InsecureAllowKeyEviction() Option
InsecureAllowKeyEviction lets New build a Store against a Redis server that is configured to evict keys under memory pressure.
Read the name literally. It does not relax a check; it accepts that the server may discard rate-limit state at any moment, and that each discarded bucket hands its client a fresh full burst — landing, for the reasons in ErrUnsafeEvictionPolicy, on whichever client has most recently been throttled. The limit becomes advisory under memory pressure.
It exists because the refusal would otherwise be a lie about deployments where eviction is a considered trade: a shared cache instance where an approximate limit is worth more than an outage at the memory ceiling, or a staging environment where nobody cares. It also skips the round trip, which suits a process that builds its store before Redis is reachable.
It is deliberately not spelled With..., and deliberately not a bool field. It should be impossible to read a call site containing it and not notice.
func WithIdleTTL ¶
WithIdleTTL sets the floor for how long an untouched bucket is kept in Redis.
It is a floor, not a ceiling: a bucket is never expired before it would have refilled completely, whatever this is set to, because expiring a partially refilled bucket resets its client's allowance. Values of zero or less are ignored.
func WithKeyPrefix ¶
WithKeyPrefix sets the namespace for bucket keys. The default is DefaultKeyPrefix.
Give each application its own prefix when they share a Redis, or one application's traffic will be counted against another's buckets.
func WithTimeout ¶
WithTimeout bounds a single Redis round trip. The default is DefaultTimeout. A value of zero or less removes the bound and relies entirely on the caller's context.
Keep it short. This call sits in front of every request, so its latency is added to every request; and with the middleware failing closed, a backend that hangs rather than refusing takes the whole endpoint down with it. A timeout converts that into a fast, visible error that the failure mode can act on.
The shorter of this timeout and the caller's existing deadline wins.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a Redis-backed ratelimit.Store.
It is safe for concurrent use. Create it with New; the zero value is not usable.
func New ¶
New returns a Store that keeps bucket state in the given client.
client is any go-redis client implementing redis.Scripter, which includes *redis.Client, *redis.ClusterClient and *redis.Ring, so the same store works against a single server, a cluster or a sharded ring.
The Store does not take ownership of the client: Store.Close leaves it open, because the client is normally shared with the rest of the application and closing it from here would break everything else using it.
In cluster mode every operation touches a single key, so no cross-slot operation is ever attempted.
Example ¶
ExampleNew wires a shared, Redis-backed limit across every instance of an application.
This example is compile-only: it needs a reachable Redis, so there is no deterministic output to assert. A flaky example teaches contributors to ignore red CI, which is a worse outcome than an unverified snippet.
package main
import (
"net/http"
"time"
"github.com/JonasBorgesLM/moat/ratelimit"
"github.com/JonasBorgesLM/moat/redisstore"
"github.com/redis/go-redis/v9"
)
func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
// Bound the dial and the round trip. This sits in front of every
// request, and with the limiter failing closed, a backend that hangs
// rather than refusing takes the endpoint down with it.
DialTimeout: 200 * time.Millisecond,
ReadTimeout: 200 * time.Millisecond,
WriteTimeout: 200 * time.Millisecond,
})
defer client.Close()
store, err := redisstore.New(client, redisstore.WithKeyPrefix("myapp:ratelimit:"))
if err != nil {
return
}
// Close releases the store; the client stays open, since it belongs to the
// application and is usually shared.
defer store.Close()
limiter := ratelimit.New(20, 5,
ratelimit.WithStore(store),
ratelimit.WithOnError(func(err error) {
// A Redis outage is invisible without this: the limiter fails
// closed, and every request is rejected with no signal to
// operators. Route it into your logger and alerting.
_ = err
}),
)
defer limiter.Close()
mux := http.NewServeMux()
// Timeouts, because net/http has none by default. Without them a slow
// client holds a connection open indefinitely -- a denial of service that
// no middleware in this library can see, let alone prevent.
srv := &http.Server{
Addr: ":8080",
Handler: limiter.Middleware(mux),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
_ = srv.ListenAndServe()
}
Output:
Example (FailOpen) ¶
ExampleNew_failOpen shows the availability trade-off. The default rejects requests when Redis is unreachable, on the grounds that a limiter which silently stops limiting during an outage is not a control. For an endpoint where being unreachable is worse than being unthrottled, say so explicitly.
Compile-only: it needs a reachable Redis.
package main
import (
"github.com/JonasBorgesLM/moat/ratelimit"
"github.com/JonasBorgesLM/moat/redisstore"
"github.com/redis/go-redis/v9"
)
func main() {
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
defer client.Close()
store, err := redisstore.New(client)
if err != nil {
return
}
defer store.Close()
limiter := ratelimit.New(100, 50,
ratelimit.WithStore(store),
ratelimit.WithFailureMode(ratelimit.FailOpen),
ratelimit.WithOnError(func(err error) { _ = err }),
)
defer limiter.Close()
}
Output:
func (*Store) Close ¶
Close releases resources held by the Store.
It does not close the Redis client. The client belongs to whoever created it and is usually shared with the rest of the application; closing it here would take out every other user of the connection pool.
func (*Store) EvictionCheck ¶ added in v0.2.2
func (s *Store) EvictionCheck() EvictionCheck
EvictionCheck reports what the construction-time eviction check established.
New refuses to build a Store against a server it can see is configured to evict keys, because eviction silently resets rate limits (see ErrUnsafeEvictionPolicy). What it cannot do is establish the answer everywhere, and when it cannot, it proceeds. That is the right trade — a library that guessed would reject working deployments it cannot inspect — but it means a Store built without error is not the same thing as a Store built against a server known to be safe.
This method is the difference, made available rather than left implicit:
store, err := redisstore.New(client)
if err != nil {
return err
}
if c := store.EvictionCheck(); !c.Verified() {
log.Printf("redisstore: eviction policy unverified (%s); confirm maxmemory-policy is noeviction", c)
}
Two gaps it will not close, both worth knowing before relying on a verified result:
A verified result is a snapshot. It describes the server's mutable runtime state at construction. `CONFIG SET maxmemory-policy allkeys-lru` is one command away, and the moment it is most likely to be run is a memory incident — exactly when the bypass matters. Nothing re-checks afterwards.
In cluster or ring mode, every master reachable at construction is asked, and only then. A node that joins later, or a replica promoted by failover with a different configuration, is not covered. Failover is also a thing that happens during memory incidents.
The honest summary is that this check closes the case of an operator who never knew, and does not make a deployment permanently safe. An operator who needs the stronger property should assert `maxmemory-policy noeviction` from configuration management, where it is an invariant rather than an observation.
Example ¶
ExampleStore_EvictionCheck surfaces a check that was skipped.
redisstore.New refuses to build a Store against a server it can see will evict keys, because eviction silently resets rate limits. What it cannot do is establish that everywhere: on ElastiCache, MemoryDB, Upstash and Azure Cache, CONFIG is disabled or renamed, so the check is skipped and construction proceeds. Those are also where most production deployments run — the check is loudest in local Docker and quietest in production.
So a Store that constructed without error has not necessarily been verified. Asking is one line, and belongs in startup logging next to the other configuration a deployment cannot infer for itself.
This example is compile-only: it needs a reachable Redis.
package main
import (
"log"
"time"
"github.com/JonasBorgesLM/moat/redisstore"
"github.com/redis/go-redis/v9"
)
func main() {
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
DialTimeout: 200 * time.Millisecond,
ReadTimeout: 200 * time.Millisecond,
WriteTimeout: 200 * time.Millisecond,
})
defer client.Close()
store, err := redisstore.New(client)
if err != nil {
// Includes redisstore.ErrUnsafeEvictionPolicy: the server was asked and
// answered that it evicts.
log.Fatalf("redis store: %v", err)
}
defer store.Close()
// Not an error, and not necessarily a problem — but it is the difference
// between "this server will not evict" and "nobody asked". Only the operator
// can close that gap, and only if they are told it is open.
if check := store.EvictionCheck(); !check.Verified() {
log.Printf("redisstore: eviction policy unverified (%s); "+
"confirm maxmemory-policy is noeviction on every node", check)
}
}
Output:
func (*Store) TakeN ¶ added in v0.2.0
func (s *Store) TakeN(ctx context.Context, key string, n int, cfg ratelimit.Config, now time.Time) (ratelimit.Result, error)
Take implements ratelimit.Store.
It runs the whole token-bucket decision in Redis in one round trip. The supplied now is the clock: this store never reads the local wall clock, so callers and tests control time exactly as the interface promises.
Any transport or protocol failure is returned. Nothing here converts a failure into an allow.