Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,76 @@ vals, err := rdb.Eval(ctx, "return {KEYS[1],ARGV[1]}", []string{"key"}, "hello")
res, err := rdb.Do(ctx, "set", "key", "value").Result()
```

## Typed Errors

go-redis provides typed error checking functions for common Redis errors:

```go
redis.IsLoadingError(err) // Redis is loading the dataset
redis.IsReadOnlyError(err) // Write to read-only replica
redis.IsClusterDownError(err) // Cluster is down
redis.IsTryAgainError(err) // Command should be retried
redis.IsMasterDownError(err) // Master is down
redis.IsMaxClientsError(err) // Maximum clients reached
redis.IsMovedError(err) // Returns (address, true) if key moved
redis.IsAskError(err) // Returns (address, true) if key being migrated
```

### Error Wrapping in Hooks

When wrapping errors in hooks, use custom error types with `Unwrap()` method (preferred) or `fmt.Errorf` with `%w`. Always call `cmd.SetErr()` to preserve error type information:

```go
// Custom error type (preferred)
type AppError struct {
Code string
RequestID string
Err error
}

func (e *AppError) Error() string {
return fmt.Sprintf("[%s] request_id=%s: %v", e.Code, e.RequestID, e.Err)
}

func (e *AppError) Unwrap() error {
return e.Err
}

// Hook implementation
func (h MyHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
err := next(ctx, cmd)
if err != nil {
// Wrap with custom error type
wrappedErr := &AppError{
Code: "REDIS_ERROR",
RequestID: getRequestID(ctx),
Err: err,
}
cmd.SetErr(wrappedErr)
return wrappedErr // Return wrapped error to preserve it
}
return nil
}
}

// Typed error detection works through wrappers
if redis.IsLoadingError(err) {
// Retry logic
}

// Extract custom error if needed
var appErr *AppError
if errors.As(err, &appErr) {
log.Printf("Request: %s", appErr.RequestID)
}
```

Alternatively, use `fmt.Errorf` with `%w`:
```go
wrappedErr := fmt.Errorf("context: %w", err)
cmd.SetErr(wrappedErr)
```

## Run the test

Expand Down
142 changes: 116 additions & 26 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,29 @@ func shouldRetry(err error, retryTimeout bool) bool {
return true
}

// Check for typed Redis errors using errors.As (works with wrapped errors)
if proto.IsMaxClientsError(err) {
return true
}
if proto.IsLoadingError(err) {
return true
}
if proto.IsReadOnlyError(err) {
return true
}
if proto.IsMasterDownError(err) {
return true
}
if proto.IsClusterDownError(err) {
return true
}
if proto.IsTryAgainError(err) {
return true
}

// Fallback to string checking for backward compatibility with plain errors
s := err.Error()
if s == "ERR max number of clients reached" {
if strings.HasPrefix(s, "ERR max number of clients reached") {
return true
}
if strings.HasPrefix(s, "LOADING ") {
Expand All @@ -88,9 +109,6 @@ func shouldRetry(err error, retryTimeout bool) bool {
if strings.HasPrefix(s, "READONLY ") {
return true
}
if strings.HasPrefix(s, "MASTERDOWN ") {
return true
}
if strings.HasPrefix(s, "CLUSTERDOWN ") {
return true
}
Expand Down Expand Up @@ -142,44 +160,116 @@ func isBadConn(err error, allowTimeout bool, addr string) bool {
}

func isMovedError(err error) (moved bool, ask bool, addr string) {
if !isRedisError(err) {
return
// Check for typed MovedError
if movedErr, ok := proto.IsMovedError(err); ok {
addr = movedErr.Addr()
addr = internal.GetAddr(addr)
return true, false, addr
}

s := err.Error()
switch {
case strings.HasPrefix(s, "MOVED "):
moved = true
case strings.HasPrefix(s, "ASK "):
ask = true
default:
return
// Check for typed AskError
if askErr, ok := proto.IsAskError(err); ok {
addr = askErr.Addr()
addr = internal.GetAddr(addr)
return false, true, addr
}

ind := strings.LastIndex(s, " ")
if ind == -1 {
return false, false, ""
// Fallback to string checking for backward compatibility
s := err.Error()
if strings.HasPrefix(s, "MOVED ") {
// Parse: MOVED 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return true, false, addr
}
}
if strings.HasPrefix(s, "ASK ") {
// Parse: ASK 3999 127.0.0.1:6381
parts := strings.Split(s, " ")
if len(parts) == 3 {
addr = internal.GetAddr(parts[2])
return false, true, addr
}
}

addr = s[ind+1:]
addr = internal.GetAddr(addr)
return
return false, false, ""
}

func isLoadingError(err error) bool {
return strings.HasPrefix(err.Error(), "LOADING ")
return proto.IsLoadingError(err)
}

func isReadOnlyError(err error) bool {
return strings.HasPrefix(err.Error(), "READONLY ")
return proto.IsReadOnlyError(err)
}

func isMovedSameConnAddr(err error, addr string) bool {
redisError := err.Error()
if !strings.HasPrefix(redisError, "MOVED ") {
return false
if movedErr, ok := proto.IsMovedError(err); ok {
return strings.HasSuffix(movedErr.Addr(), addr)
}
return false
}

//------------------------------------------------------------------------------

// Typed error checking functions for public use.
// These functions work correctly even when errors are wrapped in hooks.

// IsLoadingError checks if an error is a Redis LOADING error, even if wrapped.
// LOADING errors occur when Redis is loading the dataset in memory.
func IsLoadingError(err error) bool {
return proto.IsLoadingError(err)
}

// IsReadOnlyError checks if an error is a Redis READONLY error, even if wrapped.
// READONLY errors occur when trying to write to a read-only replica.
func IsReadOnlyError(err error) bool {
return proto.IsReadOnlyError(err)
}

// IsClusterDownError checks if an error is a Redis CLUSTERDOWN error, even if wrapped.
// CLUSTERDOWN errors occur when the cluster is down.
func IsClusterDownError(err error) bool {
return proto.IsClusterDownError(err)
}

// IsTryAgainError checks if an error is a Redis TRYAGAIN error, even if wrapped.
// TRYAGAIN errors occur when a command cannot be processed and should be retried.
func IsTryAgainError(err error) bool {
return proto.IsTryAgainError(err)
}

// IsMasterDownError checks if an error is a Redis MASTERDOWN error, even if wrapped.
// MASTERDOWN errors occur when the master is down.
func IsMasterDownError(err error) bool {
return proto.IsMasterDownError(err)
}

// IsMaxClientsError checks if an error is a Redis max clients error, even if wrapped.
// This error occurs when the maximum number of clients has been reached.
func IsMaxClientsError(err error) bool {
return proto.IsMaxClientsError(err)
}

// IsMovedError checks if an error is a Redis MOVED error, even if wrapped.
// MOVED errors occur in cluster mode when a key has been moved to a different node.
// Returns the address of the node where the key has been moved and a boolean indicating if it's a MOVED error.
func IsMovedError(err error) (addr string, ok bool) {
if movedErr, isMovedErr := proto.IsMovedError(err); isMovedErr {
return movedErr.Addr(), true
}
return "", false
}

// IsAskError checks if an error is a Redis ASK error, even if wrapped.
// ASK errors occur in cluster mode when a key is being migrated and the client should ask another node.
// Returns the address of the node to ask and a boolean indicating if it's an ASK error.
func IsAskError(err error) (addr string, ok bool) {
if askErr, isAskErr := proto.IsAskError(err); isAskErr {
return askErr.Addr(), true
}
return strings.HasSuffix(redisError, " "+addr)
return "", false
}

//------------------------------------------------------------------------------
Expand Down
15 changes: 8 additions & 7 deletions error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package redis_test

import (
"context"
"errors"
"io"

. "github.com/bsm/ginkgo/v2"
. "github.com/bsm/gomega"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/internal/proto"
)

type testTimeout struct {
Expand Down Expand Up @@ -39,12 +39,13 @@ var _ = Describe("error", func() {
context.Canceled: false,
context.DeadlineExceeded: false,
redis.ErrPoolTimeout: true,
errors.New("ERR max number of clients reached"): true,
errors.New("LOADING Redis is loading the dataset in memory"): true,
errors.New("READONLY You can't write against a read only replica"): true,
errors.New("CLUSTERDOWN The cluster is down"): true,
errors.New("TRYAGAIN Command cannot be processed, please try again"): true,
errors.New("other"): false,
// Use typed errors instead of plain errors.New()
proto.ParseErrorReply([]byte("-ERR max number of clients reached")): true,
proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory")): true,
proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica")): true,
proto.ParseErrorReply([]byte("-CLUSTERDOWN The cluster is down")): true,
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
proto.ParseErrorReply([]byte("-ERR other")): false,
}

for err, expected := range data {
Expand Down
Loading
Loading