redisstore

package module
v0.2.2 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 10 Imported by: 0

README

moat/redisstore

A Redis-backed ratelimit.Store for moat, atomic via a Lua script, so that a rate limit is shared across every instance of an application instead of enforced separately by each one.

It is a separate module so that go-redis never appears in the go.sum of consumers who only import the core packages. See doc/DESIGN.md §2.

go get github.com/JonasBorgesLM/moat/redisstore

Compatibility

The two modules are tagged independently, and their version numbers have already diverged — redisstore/v0.2.1 requires the core at v0.2.0. That is not a mistake to be tidied up later; it is what independent tagging means. It does mean the version number on its own tells you nothing about which core it works with, and the contract between them is an interface that has broken once already (ratelimit.Store.Take became TakeN in the core's v0.2.0), so the mapping is written down rather than inferred.

redisstore requires core Go toolchain floor Notes
v0.2.2 v0.2.0 1.26.6 Adds Store.EvictionCheck. Raises the go directive to enforce the stdlib floor below.
v0.2.1 v0.2.0 1.25.0 (not enforced) First version that actually compiles against the core it names.
v0.2.0 v0.1.0 1.25.0 (not enforced) Retracted, does not compile. It names a core whose ratelimit.Store still declares Take, so *Store does not satisfy the interface. See doc/DESIGN.md §6.16.
v0.1.0 v0.1.0 1.25.0 (not enforced) Compiles; verified from a clean directory against the proxy.

Go's minimal version selection takes the highest requirement in the build, so a consumer who imports redisstore gets at least the core version in the table even if they also require an older one directly.

Go toolchain floor

This module requires Go 1.26.6 or newer, declared in its go directive. The core module requires only 1.24 and is unaffected by this — the floor here is not inherited by anyone importing moat alone.

The reason is a security one rather than a language-feature one. This module reaches crypto/tls through go-redis, and encoding/asn1 through certificate parsing on a TLS connection to Redis. Both carry known vulnerabilities:

ID Package Fixed in
GO-2026-6090 crypto/tls — unbounded post-handshake messages 1.25.13, 1.26.6
GO-2026-5972 encoding/asn1 — unbounded recursion depth 1.25.13, 1.26.6

Two details in how that floor is declared, both deliberate:

  • It is the go directive, not toolchain. A toolchain line in a dependency is ignored by whoever imports it, so declaring the floor there protected this repository's CI and nobody else's build. The go directive is honoured: under the default GOTOOLCHAIN=auto a consumer on an older toolchain downloads and switches to a conforming one, and under GOTOOLCHAIN=local they get an explicit refusal rather than a silently vulnerable binary.

  • It is 1.26.6, not 1.25.13, even though 1.25.13 also carries both fixes. Go orders versions across release lines, so a floor of 1.25.13 is satisfied by 1.26.5 — which has neither fix. Only 1.26.6 covers both lines.

If a 1.26.6 floor is not workable for you, pin redisstore/v0.2.1 and enforce the toolchain version in your own build. What you must not do is assume the older tag is safe because it declares a lower number; it is the same code with an unenforced floor.

Eviction policy

Redis running under maxmemory with any eviction policy other than noeviction will discard rate-limit buckets to reclaim memory, and a discarded bucket hands its client a fresh full burst. Worse, the eviction lands on whichever bucket is least recently used or nearest to expiry — which, since every bucket's TTL is refreshed on every call, is whichever client was most recently throttled. The limit becomes advisory at exactly the moment it is doing the most work.

New refuses to build a Store against a server it can see is configured this way, returning ErrUnsafeEvictionPolicy. InsecureAllowKeyEviction() is the only way past, and is named to be noticed at the call site.

The check is best-effort, and it is quietest where it matters most. It is skipped whenever the answer cannot be established: CONFIG disabled or renamed (the normal case on ElastiCache, MemoryDB, Upstash and Azure Cache), a server unreachable at construction, a client that cannot issue CONFIG at all, or a reply that is not understood. Those are the managed platforms most production deployments run on, so the check tends to fire in local Docker and stay silent in production.

Since v0.2.2 a skipped check is reportable rather than implicit:

store, err := redisstore.New(client)
if err != nil {
    return err // includes ErrUnsafeEvictionPolicy
}
if check := store.EvictionCheck(); !check.Verified() {
    log.Printf("redisstore: eviction policy unverified (%s); "+
        "confirm maxmemory-policy is noeviction on every node", check)
}

Two gaps a verified result does not close, and neither is closable from inside a library:

  • It is a snapshot, not an invariant. It describes the server's mutable runtime state at construction. CONFIG SET maxmemory-policy allkeys-lru is one command away, and a memory incident is when it is most likely to be run — exactly when the bypass matters. Nothing re-checks afterwards. A process restarting during that incident also constructs its store against an unreachable Redis, skips the check, and comes up unverified.
  • Cluster membership is checked once. 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 — and failover is also a thing that happens during memory incidents.

If you need the stronger property, assert maxmemory-policy noeviction from configuration management, where it is an invariant rather than an observation.

Security

Vulnerability reporting for this module follows the root SECURITY.md, which also records the toolchain floor above as a supported-versions statement.

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

View Source
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

View Source
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.

View Source
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

func WithIdleTTL(d time.Duration) Option

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

func WithKeyPrefix(prefix string) Option

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

func WithTimeout(d time.Duration) Option

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

func New(client redis.Scripter, opts ...Option) (*Store, error)

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()
}
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()
}

func (*Store) Close

func (s *Store) Close() error

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)
	}
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL