mirror of
https://github.com/redis/go-redis.git
synced 2025-11-26 06:23:09 +03:00
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>
This commit is contained in:
392
internal/proto/redis_errors_test.go
Normal file
392
internal/proto/redis_errors_test.go
Normal file
@@ -0,0 +1,392 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user