redis

package module
v1.3.0 Latest Latest
Warning

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

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 12 Imported by: 3

README

go-redis 🧰

🧷 Resilient Redis wiring for Go in one call — sensible pool, timeouts, and retries by default, with sentinel failover, cluster, and a health check built in, plus an optional toolkit for the patterns every service rebuilds.

go-redis wraps redis/go-redis/v9 so a service stops re-deriving the same pool sizes, timeouts, and retry policy in every codebase. Connect returns a client that has already Pinged the server, so a successful call means you are ready to serve.

✨ Highlights

  • 🛟 Resilient by default — pool, timeouts, retries with backoff, sentinel failover and cluster from one Connect.
  • 🧰 A toolkit on top — Lua scripts, caching, streams, pub/sub, locks, idempotency keys and rate limiting, each in its own package.
  • 🪶 Pay for what you import — the core has no logging or telemetry dependency; the toolkit and otel live in subpackages.
  • 🔇 Quiet unless asked — every tool takes a go-log logger for generous diagnostics and discards everything by default.
  • 🧪 Tested against real Redis — unit tests on miniredis, integration tests on redis:7 in CI.

📦 Install

go get github.com/Bugs5382/go-redis

🚀 Usage

Connect applies resilient defaults, dials, and verifies readiness with a Ping. Redis() hands you the full go-redis command API.

client, err := redis.Connect(ctx, redis.WithAddr("localhost:6379"))
if err != nil {
	log.Fatal(err)
}
defer client.Close()

rdb := client.Redis()
rdb.Set(ctx, "session:123", "alice", time.Minute)
name, _ := rdb.Get(ctx, "session:123").Result()

🧩 Depend on go-redis only

Nil, Cmdable, and UniversalClient re-export the handful of go-redis names a caller needs in order to name the value Redis() returns, so a wrapper library can depend on go-redis alone and never import github.com/redis/go-redis/v9 directly:

var cmd redis.Cmdable = client.Redis() // or redis.UniversalClient

_, err := cmd.Get(ctx, "session:missing").Result()
if errors.Is(err, redis.Nil) {
	// cache miss
}

🛟 High availability

Point at Redis Sentinel for automatic failover — the client discovers the current master through the sentinels and re-resolves it after a switchover. Redis Cluster is one option away.

// Sentinel-managed failover master.
client, _ := redis.Connect(ctx,
	redis.WithSentinel("mymaster", "localhost:26379", "localhost:26380"),
)

// Redis Cluster.
client, _ := redis.Connect(ctx,
	redis.WithCluster("localhost:7000", "localhost:7001", "localhost:7002"),
)

🎛 Options

Every knob is a functional option, and every one has a resilient default.

client, _ := redis.Connect(ctx,
	redis.WithAddr("localhost:6379"),
	redis.WithPassword("s3cret"),
	redis.WithDB(0),
	redis.WithTLS(&tls.Config{}),
	redis.WithPool(50),
	redis.WithTimeouts(5*time.Second, 3*time.Second, 3*time.Second),
	redis.WithRetry(3, 8*time.Millisecond, 512*time.Millisecond),
)

💓 Health

Healthy is a cheap Ping — wire it straight into a readiness or liveness probe.

if !client.Healthy(ctx) {
	// fail the probe
}

⏳ Waiting for the server at startup

Connect pings once, so a service that starts before Redis or Valkey answers (after a reboot, say) gets an error. WaitFor retries while the error is Transient (network failures, and the LOADING, MASTERDOWN, TRYAGAIN and CLUSTERDOWN replies), with capped exponential backoff and jitter. Anything else, such as WRONGPASS, is returned at once.

var client *redis.Client
err := redis.WaitFor(ctx, func(ctx context.Context) error {
	c, err := redis.Connect(ctx, redis.WithAddr("cache.example.org:6379"))
	client = c
	return err
}, redis.WithRetryHook(func(attempt int, err error, delay time.Duration) {
	logger.Warn("redis not reachable yet, retrying", "attempt", attempt, "in", delay, "err", err)
}))

WithWaitBackoff(base, max) tunes the waits (default 500ms doubling to 15s).

🔌 Logging & metrics

A minimal Logger (dial diagnostics) and Observer (per-command and per-dial signals) are pluggable and default to no-ops, so the core drags in no logging or telemetry dependency.

client, _ := redis.Connect(ctx,
	redis.WithAddr("localhost:6379"),
	redis.WithObserver(myObserver),
)

📊 OpenTelemetry

The optional otel subpackage turns on tracing and metrics in one call against the global providers, keeping the dependency out of the core.

import redisotel "github.com/Bugs5382/go-redis/otel"

client, _ := redis.Connect(ctx, redis.WithAddr("localhost:6379"))
redisotel.Instrument(client) // spans + metrics per command

🧰 Toolkit

Each tool is optional, builds only on *redis.Client, and lives in its own package, so importing one pulls in nothing else. They all take a context.Context, log through a go-log Logger passed with WithLogger (keys and IDs only, never values; silent by default), and return plain wrapped errors you match with errors.Is.

Package For
script Lua scripts with EVALSHA and a transparent EVAL fallback
cache Typed get-or-set with codecs, jitter, negative caching and stampede protection
streams A resilient consumer-group consumer with retries and dead letters
pubsub A subscriber that re-subscribes after reconnects
lock A lock with token-checked release and auto-extend
idempotency Run a function once per key and replay its result
ratelimit Token-bucket and sliding-window rate limiting

📜 Lua scripts

script.New hashes the source once; Run sends EVALSHA and, when the server answers NOSCRIPT (first use, after a failover or SCRIPT FLUSH), falls back to EVAL, which caches it again. The lock, idempotency and rate-limit tools are built on it.

import "github.com/Bugs5382/go-redis/script"

incr := script.New(`return redis.call('INCRBY', KEYS[1], ARGV[1])`)
n, err := incr.Run(ctx, client, []string{"counter"}, 5).Int64()

Pass every key the script touches in keys. On a cluster client, keys that hash to different slots fail with ErrCrossSlot before anything is sent, so use a hash tag such as {order:7}:items to keep related keys together.

🗄️ Cache

A typed Cache[T] with Get, Set, GetOrSet and Delete. A miss is ErrMiss, never confused with a failure.

import "github.com/Bugs5382/go-redis/cache"

users := cache.New[User](client,
	cache.WithPrefix("users:"),
	cache.WithJitter(0.1),              // spread expiries
	cache.WithNegativeTTL(time.Minute), // remember "not found"
)
u, err := users.GetOrSet(ctx, id, 5*time.Minute, func(ctx context.Context) (User, error) {
	return db.LoadUser(ctx, id) // return cache.ErrNotFound when there is no such user
})

Concurrent GetOrSet calls for one key share a single load, within one process. If Redis is down, GetOrSet logs a warning and serves from the loader. Values are JSON unless you pass another Codec.

🌊 Streams

streams.Consume runs one consumer in a consumer group: it creates the group, reads with XREADGROUP, acks what the handler accepts, reclaims entries stuck with dead consumers (XAUTOCLAIM), and moves messages that keep failing to a dead-letter stream.

import "github.com/Bugs5382/go-redis/streams"

_, _ = streams.Publish(ctx, client, "orders", map[string]any{"order_id": "A-1"}, streams.WithMaxLen(100000))

err := streams.Consume(ctx, client, streams.Config{
	Stream:        "orders",
	Group:         "billing",
	Consumer:      podName,
	MaxDeliveries: 5, // then to "orders:dead"
}, func(ctx context.Context, msg streams.Message) error {
	return bill(ctx, msg.Values["order_id"]) // an error retries after ClaimMinIdle
})

Delivery is at least once: a message is handled again if its ack is lost, so handlers must be idempotent (the idempotency tool below pairs well). Redis errors back off and retry, and cancelling the context drains in-flight handlers before Consume returns.

📣 Pub/sub

pubsub.Subscribe returns once every channel and pattern is confirmed, then re-subscribes after a dropped connection or failover. Healthy backs a readiness probe.

import "github.com/Bugs5382/go-redis/pubsub"

sub, err := pubsub.Subscribe(ctx, client, pubsub.Topics{
	Channels: []string{"config.reload"},
	Patterns: []string{"orders.*"},
}, func(ctx context.Context, msg pubsub.Message) {
	route(ctx, msg.Channel, msg.Payload)
})
if err != nil {
	return err
}
defer sub.Close()

Redis pub/sub is at most once: messages published while the subscriber is disconnected are lost. Use streams when every message must arrive. The handler runs one message at a time, so hand slow work to your own goroutines.

🔒 Lock

lock.Acquire stores a random token with SET NX PX; Release and Extend only act while the key still holds that token, so an expired holder can never free the next holder's lock.

import "github.com/Bugs5382/go-redis/lock"

l, err := lock.Acquire(ctx, client, "jobs:nightly-report", 30*time.Second,
	lock.WithWait(5*time.Second),        // retry with backoff instead of failing fast
	lock.WithAutoExtend(10*time.Second), // keep it alive while the job runs
)
if errors.Is(err, lock.ErrNotAcquired) {
	return nil // another worker has it
}
defer l.Release(ctx)
// stop work if <-l.Lost() fires

This is a single-instance lock, not Redlock. Redis replicates asynchronously, so a failover can hand the same lock to a second client, and a long pause can outlive the TTL. Use it freely to avoid duplicate work; when overlapping holders would corrupt data, add a fencing token or make the downstream idempotent.

🔁 Idempotency

Store[T].Do runs a function at most once per key within a TTL and replays the stored result to duplicates, which is what a retried request or a redelivered message needs.

import "github.com/Bugs5382/go-redis/idempotency"

payments := idempotency.New[Receipt](client, idempotency.WithLease(30*time.Second))
r, err := payments.Do(ctx, requestID, 24*time.Hour, func(ctx context.Context) (Receipt, error) {
	return charge(ctx, order)
})
// a duplicate while charge runs gets idempotency.ErrInProgress (or waits, with WithWait)

On failure the key is released for a retry by default; KeepOnError stores the failure instead. The lease frees keys left by a caller that crashed mid-run, so keep it above the function's longest run. Like the lock, the guarantee is as strong as the Redis primary holding the key.

🚦 Rate limiting

ratelimit decides each request with one atomic Lua script per key, timed by the Redis server clock, so every app server agrees. It works on cluster.

import "github.com/Bugs5382/go-redis/ratelimit"

limiter := ratelimit.New(client) // token bucket; ratelimit.WithStrategy(ratelimit.SlidingWindow) for an exact window
r, err := limiter.Allow(ctx, "api:"+userID, ratelimit.Limit{Rate: 100, Period: time.Minute, Burst: 20})
if err != nil {
	return err
}
if !r.Allowed {
	return tooManyRequests(r.RetryAfter)
}

The token bucket allows bursts up to Burst and refills at Rate per Period; the sliding window never admits more than Rate in any Period. It needs Redis 5 or newer (the scripts call TIME).

🛠 Develop

task build    # go build ./...
task test     # go test ./...
task ci       # build + vet + race tests + linters
task license  # verify MIT headers (golic)

Integration tests run against a live server and are excluded from the default build:

REDIS_ADDR=localhost:6379 task test-integration

⚖️ License

MIT © 2026 Shane

Documentation

Overview

Package redis wraps github.com/redis/go-redis/v9 with resilient defaults so a service gets a correctly configured Redis client from a single Connect call: a sensible pool, bounded timeouts, a few retries with backoff, and a startup Ping that verifies readiness. Functional options select a standalone server, a sentinel-managed failover master, or a cluster, and tune pool, timeouts, retries, TLS, and pluggable Logger/Observer hooks. The returned Client exposes the underlying go-redis UniversalClient for the full command API.

Nil, Cmdable, and UniversalClient re-export the handful of go-redis names a caller needs to work with Client.Redis's return value -- the miss sentinel and the command/client interfaces -- so a consumer of this package never needs to import github.com/redis/go-redis/v9 directly.

Index

Examples

Constants

This section is empty.

Variables

View Source
var Nil = goredis.Nil

Nil is the sentinel error go-redis returns when a command finds no result (for example GET on a missing key). It is re-exported here so a caller can write errors.Is(err, redis.Nil) without importing github.com/redis/go-redis/v9 directly.

Functions

func Transient added in v1.3.0

func Transient(err error) bool

Transient reports whether err means the server is not reachable or not ready yet, as opposed to a failure that retrying will not fix. It is true for network failures (refused or reset connections, failed DNS lookups, timeouts, a cut connection) and for the LOADING, MASTERDOWN, TRYAGAIN and CLUSTERDOWN replies. Any other reply, such as WRONGPASS or NOAUTH, is not transient, and neither is a closed client. It unwraps the chain, so the errors Connect returns qualify.

func WaitFor added in v1.3.0

func WaitFor(ctx context.Context, fn func(context.Context) error, opts ...WaitOption) error

WaitFor calls fn until it succeeds, retrying while its error is Transient, so a service can start before its Redis or Valkey answers instead of exiting. A non-transient error is returned at once. When ctx ends first, the error wraps both ctx's error and the last attempt's. Each wait is a capped exponential backoff with jitter. fn is typically a Connect call.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a thin, resilient wrapper over a go-redis UniversalClient. Build one with Connect; use Redis to reach the full go-redis command API, Healthy for a liveness probe, and Close to release the pool. A Client is safe for concurrent use by multiple goroutines.

func Connect

func Connect(ctx context.Context, opts ...Option) (*Client, error)

Connect builds a Redis client from the given options, applies the resilient defaults for anything left unset, and verifies readiness with a Ping before returning. The mode (standalone, sentinel, or cluster) is chosen by the WithAddr, WithSentinel, or WithCluster option; standalone localhost:6379 is the default. On a failed Ping the underlying client is closed and the error is returned, so a returned Client is always usable. The ctx bounds both the dial and the readiness Ping.

Example

Connect to a standalone server with the resilient defaults, then read and write through the underlying go-redis client.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	redis "github.com/Bugs5382/go-redis"
)

func main() {
	ctx := context.Background()

	client, err := redis.Connect(ctx, redis.WithAddr("localhost:6379"))
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = client.Close() }()

	rdb := client.Redis()
	if err := rdb.Set(ctx, "session:123", "alice", time.Minute).Err(); err != nil {
		log.Fatal(err)
	}
	name, err := rdb.Get(ctx, "session:123").Result()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(name)
}
Example (Sentinel)

Connect through Redis Sentinel for high availability: the client discovers the current master through the sentinels and re-resolves it after a failover.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	redis "github.com/Bugs5382/go-redis"
)

func main() {
	ctx := context.Background()

	client, err := redis.Connect(ctx,
		redis.WithSentinel("mymaster", "localhost:26379", "localhost:26380"),
		redis.WithPassword("s3cret"),
		redis.WithPool(50),
		redis.WithTimeouts(5*time.Second, 3*time.Second, 3*time.Second),
		redis.WithRetry(3, 8*time.Millisecond, 512*time.Millisecond),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = client.Close() }()

	if client.Healthy(ctx) {
		fmt.Println("ready")
	}
}

func (*Client) Close

func (c *Client) Close() error

Close releases the connection pool. It is safe to call once; further use of the Client after Close returns errors from go-redis.

func (*Client) Healthy

func (c *Client) Healthy(ctx context.Context) bool

Healthy reports whether the server answers a Ping within ctx. It is cheap and suitable for readiness and liveness probes.

func (*Client) Redis

func (c *Client) Redis() UniversalClient

Redis returns the underlying client as the UniversalClient alias so callers can use the full command API (Get, Set, pipelines, pub/sub, scripts, and so on) while naming only this package's exported types. The returned value is owned by this Client; do not Close it directly -- use Client.Close.

Example

Name the command surface with this package's own alias -- Cmdable -- and check a miss against the re-exported Nil sentinel. Neither line requires importing github.com/redis/go-redis/v9.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	redis "github.com/Bugs5382/go-redis"
)

func main() {
	ctx := context.Background()

	client, err := redis.Connect(ctx, redis.WithAddr("localhost:6379"))
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = client.Close() }()

	var cmd redis.Cmdable = client.Redis()
	_, err = cmd.Get(ctx, "session:missing").Result()
	if errors.Is(err, redis.Nil) {
		fmt.Println("cache miss")
	}
}

type Cmdable added in v1.1.0

type Cmdable = goredis.Cmdable

Cmdable is the full go-redis command surface (Get, Set, pipelines, pub/sub, scripts, and so on), re-exported as a type alias so a caller can name it -- for example to store the result of Client.Redis in a typed field or accept it as a function parameter -- without importing github.com/redis/go-redis/v9 directly.

type Logger

type Logger interface {
	// Printf logs a formatted message. ctx carries the call's context, which
	// may hold trace information.
	Printf(ctx context.Context, format string, args ...any)
}

Logger receives connection-level diagnostic messages. Implementations must be safe for concurrent use. The default is a no-op, so the core carries no logging dependency.

type Observer

type Observer interface {
	// ObserveCommand is called after each command completes. name is the Redis
	// command name, dur is how long it took, and err is its result (nil on
	// success; note goredis.Nil signals a missing key, not a failure).
	ObserveCommand(ctx context.Context, name string, dur time.Duration, err error)
	// ObserveDial is called after each attempt to open a new connection.
	ObserveDial(ctx context.Context, network, addr string, dur time.Duration, err error)
}

Observer receives lightweight per-command and per-dial signals, suitable for driving metrics without pulling a telemetry library into the core. Implementations must be safe for concurrent use and must not block. The default is a no-op. For full OpenTelemetry tracing and metrics, use the otel subpackage instead.

type Option

type Option func(*config)

Option configures a Client. Options are applied in order by Connect; a later Option wins over an earlier one.

func WithAddr

func WithAddr(addrs ...string) Option

WithAddr targets a standalone Redis server. The first address is used; extra addresses are ignored (use WithCluster for multiple nodes). Overrides the default localhost:6379.

func WithCluster

func WithCluster(addrs ...string) Option

WithCluster targets a Redis Cluster. addrs are seed node endpoints; the client discovers the rest of the topology and reshards transparently. Note: cluster mode ignores WithDB (Redis Cluster supports only database 0).

func WithDB

func WithDB(db int) Option

WithDB selects the logical database index. Ignored in cluster mode.

func WithLogger

func WithLogger(l Logger) Option

WithLogger installs a Logger for connection-level diagnostics (dial failures). The default is a no-op; the core imposes no logging dependency.

func WithObserver

func WithObserver(o Observer) Option

WithObserver installs an Observer for lightweight command and dial metrics. The default is a no-op; the core imposes no telemetry dependency.

func WithPassword

func WithPassword(password string) Option

WithPassword sets the AUTH password. Leave unset for an unauthenticated server.

func WithPool

func WithPool(size int) Option

WithPool sets the maximum number of socket connections in the pool. A non-positive size is ignored, keeping the resilient default.

func WithRetry

func WithRetry(attempts int, min, max time.Duration) Option

WithRetry tunes command retries: attempts is the maximum number of retries after the first try (mapped to go-redis MaxRetries), and min/max bound the exponential backoff between them. A negative attempts count is ignored; pass 0 to disable retries. Non-positive backoff bounds are left at their defaults.

func WithSentinel

func WithSentinel(masterName string, addrs ...string) Option

WithSentinel targets a Redis Sentinel deployment: masterName is the monitored master's name (commonly "mymaster") and addrs are the sentinel endpoints. This is the high-availability path -- go-redis discovers the current master through the sentinels and re-resolves it after a failover.

func WithTLS

func WithTLS(cfg *tls.Config) Option

WithTLS enables TLS using the given configuration. Pass a non-nil *tls.Config (an empty &tls.Config{} uses the system roots and the server's hostname). A nil config leaves TLS disabled.

func WithTimeouts

func WithTimeouts(dial, read, write time.Duration) Option

WithTimeouts overrides the dial, read, and write timeouts. A non-positive duration for any parameter leaves that timeout at its default.

type UniversalClient added in v1.1.0

type UniversalClient = goredis.UniversalClient

UniversalClient is the client-mode-agnostic go-redis interface (standalone, sentinel-failover, or cluster) that Client wraps, re-exported as a type alias for the same reason as Cmdable. It embeds Cmdable and additionally exposes hooks, transactions, pub/sub, and pool stats.

type WaitOption added in v1.3.0

type WaitOption func(*waitConfig)

WaitOption configures WaitFor.

func WithRetryHook added in v1.3.0

func WithRetryHook(hook func(attempt int, err error, delay time.Duration)) WaitOption

WithRetryHook calls hook after each transient failure, before the wait: attempt is 1-based, err is the failure and delay the wait that follows. Use it to log one line per attempt.

func WithWaitBackoff added in v1.3.0

func WithWaitBackoff(base, max time.Duration) WaitOption

WithWaitBackoff sets the delay before the first retry (base) and its cap (max); each wait doubles up to max, with jitter. Non-positive values keep the defaults (500ms and 15s).

Directories

Path Synopsis
Package cache adds typed get-or-set caching on top of a go-redis Client, with pluggable codecs, key namespacing, TTL jitter, negative caching and stampede protection.
Package cache adds typed get-or-set caching on top of a go-redis Client, with pluggable codecs, key namespacing, TTL jitter, negative caching and stampede protection.
Package idempotency runs a function at most once per key within a TTL and replays its result, built on a go-redis Client and the script package.
Package idempotency runs a function at most once per key within a TTL and replays its result, built on a go-redis Client and the script package.
Package lock is a Redis lock with safe release and optional auto-extend, built on a go-redis Client and the script package.
Package lock is a Redis lock with safe release and optional auto-extend, built on a go-redis Client and the script package.
Package otel adds optional OpenTelemetry tracing and metrics to a go-redis Client in one call.
Package otel adds optional OpenTelemetry tracing and metrics to a go-redis Client in one call.
Package pubsub is a Redis pub/sub subscriber that re-subscribes to its channels and patterns after a reconnect or failover, with its health visible to readiness probes.
Package pubsub is a Redis pub/sub subscriber that re-subscribes to its channels and patterns after a reconnect or failover, with its health visible to readiness probes.
Package ratelimit is a distributed rate limiter backed by atomic Lua scripts, built on a go-redis Client and the script package.
Package ratelimit is a distributed rate limiter backed by atomic Lua scripts, built on a go-redis Client and the script package.
Package script runs Lua scripts on a go-redis Client with EVALSHA, falling back to EVAL when the server answers NOSCRIPT.
Package script runs Lua scripts on a go-redis Client with EVALSHA, falling back to EVAL when the server answers NOSCRIPT.
Package streams is a resilient Redis Streams consumer-group consumer, with a matching Publish, built on a go-redis Client.
Package streams is a resilient Redis Streams consumer-group consumer, with a matching Publish, built on a go-redis Client.

Jump to

Keyboard shortcuts

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