1
0
mirror of https://github.com/redis/go-redis.git synced 2025-11-26 06:23:09 +03:00
Files
go-redis/internal/proto/redis_errors_test.go
Nedyalko Dyakov 6c24f600de feat(errors): Introduce typed errors (#3602)
* typed errors

* add error documentation

* backwards compatibility

* update readme, remove Is methods

* Update internal/proto/redis_errors.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update internal/proto/redis_errors.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* support error wrapping for io and context errors

* use unwrapping of errors in push for consistency

* add common error types

* fix test

* fix flaky test

* add comments in the example

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 17:31:54 +02:00

393 lines
12 KiB
Go

package proto
import (
"errors"
"fmt"
"testing"
)
// TestTypedRedisErrors tests that typed Redis errors are created correctly
func TestTypedRedisErrors(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedType interface{}
expectedMsg string
checkFunc func(error) bool
extractAddr func(error) string
}{
{
name: "LOADING error",
errorMsg: "LOADING Redis is loading the dataset in memory",
expectedType: &LoadingError{},
expectedMsg: "LOADING Redis is loading the dataset in memory",
checkFunc: IsLoadingError,
},
{
name: "READONLY error",
errorMsg: "READONLY You can't write against a read only replica",
expectedType: &ReadOnlyError{},
expectedMsg: "READONLY You can't write against a read only replica",
checkFunc: IsReadOnlyError,
},
{
name: "MOVED error",
errorMsg: "MOVED 3999 127.0.0.1:6381",
expectedType: &MovedError{},
expectedMsg: "MOVED 3999 127.0.0.1:6381",
checkFunc: func(err error) bool {
_, ok := IsMovedError(err)
return ok
},
extractAddr: func(err error) string {
if movedErr, ok := IsMovedError(err); ok {
return movedErr.Addr()
}
return ""
},
},
{
name: "ASK error",
errorMsg: "ASK 3999 127.0.0.1:6381",
expectedType: &AskError{},
expectedMsg: "ASK 3999 127.0.0.1:6381",
checkFunc: func(err error) bool {
_, ok := IsAskError(err)
return ok
},
extractAddr: func(err error) string {
if askErr, ok := IsAskError(err); ok {
return askErr.Addr()
}
return ""
},
},
{
name: "CLUSTERDOWN error",
errorMsg: "CLUSTERDOWN The cluster is down",
expectedType: &ClusterDownError{},
expectedMsg: "CLUSTERDOWN The cluster is down",
checkFunc: IsClusterDownError,
},
{
name: "TRYAGAIN error",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
expectedType: &TryAgainError{},
expectedMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
checkFunc: IsTryAgainError,
},
{
name: "MASTERDOWN error",
errorMsg: "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'",
expectedType: &MasterDownError{},
expectedMsg: "MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'",
checkFunc: IsMasterDownError,
},
{
name: "Max clients error",
errorMsg: "ERR max number of clients reached",
expectedType: &MaxClientsError{},
expectedMsg: "ERR max number of clients reached",
checkFunc: IsMaxClientsError,
},
{
name: "NOAUTH error",
errorMsg: "NOAUTH Authentication required",
expectedType: &AuthError{},
expectedMsg: "NOAUTH Authentication required",
checkFunc: IsAuthError,
},
{
name: "WRONGPASS error",
errorMsg: "WRONGPASS invalid username-password pair",
expectedType: &AuthError{},
expectedMsg: "WRONGPASS invalid username-password pair",
checkFunc: IsAuthError,
},
{
name: "unauthenticated error",
errorMsg: "ERR unauthenticated",
expectedType: &AuthError{},
expectedMsg: "ERR unauthenticated",
checkFunc: IsAuthError,
},
{
name: "NOPERM error",
errorMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
expectedType: &PermissionError{},
expectedMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
checkFunc: IsPermissionError,
},
{
name: "EXECABORT error",
errorMsg: "EXECABORT Transaction discarded because of previous errors",
expectedType: &ExecAbortError{},
expectedMsg: "EXECABORT Transaction discarded because of previous errors",
checkFunc: IsExecAbortError,
},
{
name: "OOM error",
errorMsg: "OOM command not allowed when used memory > 'maxmemory'",
expectedType: &OOMError{},
expectedMsg: "OOM command not allowed when used memory > 'maxmemory'",
checkFunc: IsOOMError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
// Check error message is preserved
if err.Error() != tt.expectedMsg {
t.Errorf("Error message mismatch: got %q, want %q", err.Error(), tt.expectedMsg)
}
// Check error type using errors.As
if !errors.As(err, &tt.expectedType) {
t.Errorf("Error type mismatch: expected %T, got %T", tt.expectedType, err)
}
// Check using the helper function
if tt.checkFunc != nil && !tt.checkFunc(err) {
t.Errorf("Helper function returned false for error: %v", err)
}
// Check address extraction for MOVED/ASK errors
if tt.extractAddr != nil {
addr := tt.extractAddr(err)
if addr == "" {
t.Errorf("Failed to extract address from error: %v", err)
}
}
})
}
}
// TestWrappedTypedErrors tests that typed errors work correctly when wrapped
func TestWrappedTypedErrors(t *testing.T) {
tests := []struct {
name string
errorMsg string
checkFunc func(error) bool
}{
{
name: "Wrapped LOADING error",
errorMsg: "LOADING Redis is loading the dataset in memory",
checkFunc: IsLoadingError,
},
{
name: "Wrapped READONLY error",
errorMsg: "READONLY You can't write against a read only replica",
checkFunc: IsReadOnlyError,
},
{
name: "Wrapped CLUSTERDOWN error",
errorMsg: "CLUSTERDOWN The cluster is down",
checkFunc: IsClusterDownError,
},
{
name: "Wrapped TRYAGAIN error",
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
checkFunc: IsTryAgainError,
},
{
name: "Wrapped MASTERDOWN error",
errorMsg: "MASTERDOWN Link with MASTER is down",
checkFunc: IsMasterDownError,
},
{
name: "Wrapped Max clients error",
errorMsg: "ERR max number of clients reached",
checkFunc: IsMaxClientsError,
},
{
name: "Wrapped NOAUTH error",
errorMsg: "NOAUTH Authentication required",
checkFunc: IsAuthError,
},
{
name: "Wrapped WRONGPASS error",
errorMsg: "WRONGPASS invalid username-password pair",
checkFunc: IsAuthError,
},
{
name: "Wrapped unauthenticated error",
errorMsg: "ERR unauthenticated",
checkFunc: IsAuthError,
},
{
name: "Wrapped NOPERM error",
errorMsg: "NOPERM this user has no permissions to run the 'flushdb' command",
checkFunc: IsPermissionError,
},
{
name: "Wrapped EXECABORT error",
errorMsg: "EXECABORT Transaction discarded because of previous errors",
checkFunc: IsExecAbortError,
},
{
name: "Wrapped OOM error",
errorMsg: "OOM command not allowed when used memory > 'maxmemory'",
checkFunc: IsOOMError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the typed error
err := parseTypedRedisError(tt.errorMsg)
// Wrap it multiple times (simulating hook wrapping)
wrappedErr := fmt.Errorf("hook error: %w", err)
doubleWrappedErr := fmt.Errorf("another wrapper: %w", wrappedErr)
// Check that the helper function still works with wrapped errors
if !tt.checkFunc(doubleWrappedErr) {
t.Errorf("Helper function failed to detect wrapped error: %v", doubleWrappedErr)
}
// Verify the original error message is still accessible
if !errors.Is(doubleWrappedErr, err) {
t.Errorf("errors.Is failed to match wrapped error")
}
})
}
}
// TestMovedAndAskErrorAddressExtraction tests address extraction from MOVED/ASK errors
func TestMovedAndAskErrorAddressExtraction(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedAddr string
}{
{
name: "MOVED with IP address",
errorMsg: "MOVED 3999 127.0.0.1:6381",
expectedAddr: "127.0.0.1:6381",
},
{
name: "MOVED with hostname",
errorMsg: "MOVED 3999 redis-node-1:6379",
expectedAddr: "redis-node-1:6379",
},
{
name: "ASK with IP address",
errorMsg: "ASK 3999 192.168.1.100:6380",
expectedAddr: "192.168.1.100:6380",
},
{
name: "ASK with hostname",
errorMsg: "ASK 3999 redis-node-2:6379",
expectedAddr: "redis-node-2:6379",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
var addr string
if movedErr, ok := IsMovedError(err); ok {
addr = movedErr.Addr()
} else if askErr, ok := IsAskError(err); ok {
addr = askErr.Addr()
} else {
t.Fatalf("Error is neither MOVED nor ASK: %v", err)
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
}
// Test with wrapped error
wrappedErr := fmt.Errorf("wrapped: %w", err)
if movedErr, ok := IsMovedError(wrappedErr); ok {
addr = movedErr.Addr()
} else if askErr, ok := IsAskError(wrappedErr); ok {
addr = askErr.Addr()
} else {
t.Fatalf("Wrapped error is neither MOVED nor ASK: %v", wrappedErr)
}
if addr != tt.expectedAddr {
t.Errorf("Address mismatch in wrapped error: got %q, want %q", addr, tt.expectedAddr)
}
})
}
}
// TestGenericRedisError tests that unknown Redis errors fall back to generic RedisError
func TestGenericRedisError(t *testing.T) {
tests := []struct {
name string
errorMsg string
}{
{
name: "Generic error",
errorMsg: "ERR unknown command",
},
{
name: "WRONGTYPE error",
errorMsg: "WRONGTYPE Operation against a key holding the wrong kind of value",
},
{
name: "BUSYKEY error",
errorMsg: "BUSYKEY Target key name already exists",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := parseTypedRedisError(tt.errorMsg)
// Should be a generic RedisError
if _, ok := err.(RedisError); !ok {
t.Errorf("Expected RedisError, got %T", err)
}
// Should preserve the error message
if err.Error() != tt.errorMsg {
t.Errorf("Error message mismatch: got %q, want %q", err.Error(), tt.errorMsg)
}
// Should not match any typed error checks
if IsLoadingError(err) || IsReadOnlyError(err) || IsClusterDownError(err) ||
IsTryAgainError(err) || IsMasterDownError(err) || IsMaxClientsError(err) ||
IsAuthError(err) || IsPermissionError(err) || IsExecAbortError(err) || IsOOMError(err) {
t.Errorf("Generic error incorrectly matched a typed error check")
}
})
}
}
// TestBackwardCompatibility tests that error messages remain unchanged
func TestBackwardCompatibility(t *testing.T) {
// This test ensures that the error messages are exactly the same as before
// to maintain backward compatibility with code that checks error messages
tests := []struct {
input string
expected string
}{
{"LOADING Redis is loading the dataset in memory", "LOADING Redis is loading the dataset in memory"},
{"READONLY You can't write against a read only replica", "READONLY You can't write against a read only replica"},
{"MOVED 3999 127.0.0.1:6381", "MOVED 3999 127.0.0.1:6381"},
{"ASK 3999 127.0.0.1:6381", "ASK 3999 127.0.0.1:6381"},
{"CLUSTERDOWN The cluster is down", "CLUSTERDOWN The cluster is down"},
{"TRYAGAIN Multiple keys request during rehashing of slot", "TRYAGAIN Multiple keys request during rehashing of slot"},
{"MASTERDOWN Link with MASTER is down", "MASTERDOWN Link with MASTER is down"},
{"ERR max number of clients reached", "ERR max number of clients reached"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := parseTypedRedisError(tt.input)
if err.Error() != tt.expected {
t.Errorf("Error message changed! Got %q, want %q", err.Error(), tt.expected)
}
})
}
}