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:
728
error_wrapping_test.go
Normal file
728
error_wrapping_test.go
Normal file
@@ -0,0 +1,728 @@
|
||||
package redis_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/internal/proto"
|
||||
)
|
||||
|
||||
// TestTypedErrorsWithHookWrapping demonstrates that typed errors work correctly
|
||||
// even when wrapped by hooks, which is the main improvement of this change.
|
||||
func TestTypedErrorsWithHookWrapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errorMsg string
|
||||
checkFunc func(error) bool
|
||||
testName string
|
||||
}{
|
||||
{
|
||||
name: "LOADING error wrapped in hook",
|
||||
errorMsg: "LOADING Redis is loading the dataset in memory",
|
||||
checkFunc: redis.IsLoadingError,
|
||||
testName: "IsLoadingError",
|
||||
},
|
||||
{
|
||||
name: "READONLY error wrapped in hook",
|
||||
errorMsg: "READONLY You can't write against a read only replica",
|
||||
checkFunc: redis.IsReadOnlyError,
|
||||
testName: "IsReadOnlyError",
|
||||
},
|
||||
{
|
||||
name: "CLUSTERDOWN error wrapped in hook",
|
||||
errorMsg: "CLUSTERDOWN The cluster is down",
|
||||
checkFunc: redis.IsClusterDownError,
|
||||
testName: "IsClusterDownError",
|
||||
},
|
||||
{
|
||||
name: "TRYAGAIN error wrapped in hook",
|
||||
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
|
||||
checkFunc: redis.IsTryAgainError,
|
||||
testName: "IsTryAgainError",
|
||||
},
|
||||
{
|
||||
name: "MASTERDOWN error wrapped in hook",
|
||||
errorMsg: "MASTERDOWN Link with MASTER is down",
|
||||
checkFunc: redis.IsMasterDownError,
|
||||
testName: "IsMasterDownError",
|
||||
},
|
||||
{
|
||||
name: "Max clients error wrapped in hook",
|
||||
errorMsg: "ERR max number of clients reached",
|
||||
checkFunc: redis.IsMaxClientsError,
|
||||
testName: "IsMaxClientsError",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate a Redis error being created
|
||||
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
|
||||
|
||||
// Simulate hook wrapping the error
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", parsedErr)
|
||||
doubleWrappedErr := fmt.Errorf("another hook: %w", wrappedErr)
|
||||
|
||||
// Test that the typed error check works with wrapped errors
|
||||
if !tt.checkFunc(doubleWrappedErr) {
|
||||
t.Errorf("%s failed to detect wrapped error: %v", tt.testName, doubleWrappedErr)
|
||||
}
|
||||
|
||||
// Test that the error message is still accessible
|
||||
if !errors.Is(doubleWrappedErr, parsedErr) {
|
||||
t.Errorf("errors.Is failed to match wrapped error")
|
||||
}
|
||||
|
||||
// Test that the original error message is preserved in the chain
|
||||
expectedMsg := tt.errorMsg
|
||||
if parsedErr.Error() != expectedMsg {
|
||||
t.Errorf("Error message changed: got %q, want %q", parsedErr.Error(), expectedMsg)
|
||||
}
|
||||
|
||||
// Verify the generic RedisError interface still works
|
||||
var redisError redis.Error
|
||||
if !errors.As(doubleWrappedErr, &redisError) {
|
||||
t.Errorf("Failed to extract redis.Error from wrapped error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMovedAndAskErrorsWithHookWrapping tests MOVED and ASK errors with wrapping
|
||||
func TestMovedAndAskErrorsWithHookWrapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errorMsg string
|
||||
expectedAddr string
|
||||
isMoved bool
|
||||
}{
|
||||
{
|
||||
name: "MOVED error",
|
||||
errorMsg: "MOVED 3999 127.0.0.1:6381",
|
||||
expectedAddr: "127.0.0.1:6381",
|
||||
isMoved: true,
|
||||
},
|
||||
{
|
||||
name: "ASK error",
|
||||
errorMsg: "ASK 3999 192.168.1.100:6380",
|
||||
expectedAddr: "192.168.1.100:6380",
|
||||
isMoved: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create the error
|
||||
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
|
||||
|
||||
// Wrap it in hooks
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", parsedErr)
|
||||
doubleWrappedErr := fmt.Errorf("another hook: %w", wrappedErr)
|
||||
|
||||
// Test address extraction from wrapped error
|
||||
if tt.isMoved {
|
||||
addr, ok := redis.IsMovedError(doubleWrappedErr)
|
||||
if !ok {
|
||||
t.Errorf("IsMovedError failed to detect wrapped MOVED error")
|
||||
}
|
||||
if addr != tt.expectedAddr {
|
||||
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
|
||||
}
|
||||
} else {
|
||||
addr, ok := redis.IsAskError(doubleWrappedErr)
|
||||
if !ok {
|
||||
t.Errorf("IsAskError failed to detect wrapped ASK error")
|
||||
}
|
||||
if addr != tt.expectedAddr {
|
||||
t.Errorf("Address mismatch: got %q, want %q", addr, tt.expectedAddr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackwardCompatibilityWithStringChecks verifies that old string-based
|
||||
// error checking still works for backward compatibility
|
||||
func TestBackwardCompatibilityWithStringChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errorMsg string
|
||||
stringPrefix string
|
||||
}{
|
||||
{
|
||||
name: "LOADING error",
|
||||
errorMsg: "LOADING Redis is loading the dataset in memory",
|
||||
stringPrefix: "LOADING ",
|
||||
},
|
||||
{
|
||||
name: "READONLY error",
|
||||
errorMsg: "READONLY You can't write against a read only replica",
|
||||
stringPrefix: "READONLY ",
|
||||
},
|
||||
{
|
||||
name: "CLUSTERDOWN error",
|
||||
errorMsg: "CLUSTERDOWN The cluster is down",
|
||||
stringPrefix: "CLUSTERDOWN ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parsedErr := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
|
||||
|
||||
// Old-style string checking should still work
|
||||
errMsg := parsedErr.Error()
|
||||
if errMsg != tt.errorMsg {
|
||||
t.Errorf("Error message mismatch: got %q, want %q", errMsg, tt.errorMsg)
|
||||
}
|
||||
|
||||
// String prefix checking should still work
|
||||
if len(errMsg) < len(tt.stringPrefix) || errMsg[:len(tt.stringPrefix)] != tt.stringPrefix {
|
||||
t.Errorf("String prefix check failed: error %q doesn't start with %q", errMsg, tt.stringPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWrappingInHookScenario simulates a real-world scenario where
|
||||
// a hook wraps errors for logging or instrumentation
|
||||
func TestErrorWrappingInHookScenario(t *testing.T) {
|
||||
// Simulate a hook that wraps errors for logging
|
||||
wrapErrorForLogging := func(err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("logged error at %s: %w", "2024-01-01T00:00:00Z", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Simulate a hook that adds context
|
||||
addContextToError := func(err error, cmd string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("command %s failed: %w", cmd, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a LOADING error
|
||||
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
|
||||
|
||||
// Wrap it through multiple hooks
|
||||
err := loadingErr
|
||||
err = wrapErrorForLogging(err)
|
||||
err = addContextToError(err, "GET mykey")
|
||||
|
||||
// The typed error check should still work
|
||||
if !redis.IsLoadingError(err) {
|
||||
t.Errorf("IsLoadingError failed to detect error through multiple hook wrappers")
|
||||
}
|
||||
|
||||
// The error message should contain all the context
|
||||
errMsg := err.Error()
|
||||
expectedSubstrings := []string{
|
||||
"command GET mykey failed",
|
||||
"logged error at",
|
||||
"LOADING Redis is loading the dataset in memory",
|
||||
}
|
||||
|
||||
for _, substr := range expectedSubstrings {
|
||||
if !contains(errMsg, substr) {
|
||||
t.Errorf("Error message missing expected substring %q: %s", substr, errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldRetryWithTypedErrors tests that shouldRetry works with typed errors
|
||||
func TestShouldRetryWithTypedErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errorMsg string
|
||||
shouldRetry bool
|
||||
retryTimeout bool
|
||||
}{
|
||||
{
|
||||
name: "LOADING error should retry",
|
||||
errorMsg: "LOADING Redis is loading the dataset in memory",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "READONLY error should retry",
|
||||
errorMsg: "READONLY You can't write against a read only replica",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "CLUSTERDOWN error should retry",
|
||||
errorMsg: "CLUSTERDOWN The cluster is down",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "TRYAGAIN error should retry",
|
||||
errorMsg: "TRYAGAIN Multiple keys request during rehashing of slot",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "MASTERDOWN error should retry",
|
||||
errorMsg: "MASTERDOWN Link with MASTER is down",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
{
|
||||
name: "Max clients error should retry",
|
||||
errorMsg: "ERR max number of clients reached",
|
||||
shouldRetry: true,
|
||||
retryTimeout: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := proto.ParseErrorReply([]byte("-" + tt.errorMsg))
|
||||
|
||||
// Wrap the error
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", err)
|
||||
|
||||
// Test shouldRetry (using the exported ShouldRetry for testing)
|
||||
result := redis.ShouldRetry(wrappedErr, tt.retryTimeout)
|
||||
if result != tt.shouldRetry {
|
||||
t.Errorf("ShouldRetry returned %v, want %v for error: %v", result, tt.shouldRetry, wrappedErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetErrWithWrappedError tests that when a hook wraps an error and sets it
|
||||
// via cmd.SetErr(), the underlying typed error can still be detected
|
||||
func TestSetErrWithWrappedError(t *testing.T) {
|
||||
testCtx := context.Background()
|
||||
|
||||
// Test with a simulated LOADING error
|
||||
// We test the mechanism directly without needing a real Redis server
|
||||
cmd := redis.NewStatusCmd(testCtx, "GET", "key")
|
||||
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
|
||||
wrappedLoadingErr := fmt.Errorf("hook wrapper: %w", loadingErr)
|
||||
cmd.SetErr(wrappedLoadingErr)
|
||||
|
||||
// Verify we can still detect the LOADING error through the wrapper
|
||||
if !redis.IsLoadingError(cmd.Err()) {
|
||||
t.Errorf("IsLoadingError failed to detect wrapped error set via SetErr: %v", cmd.Err())
|
||||
}
|
||||
|
||||
// Test with MOVED error
|
||||
cmd2 := redis.NewStatusCmd(testCtx, "GET", "key")
|
||||
movedErr := proto.ParseErrorReply([]byte("-MOVED 3999 127.0.0.1:6381"))
|
||||
wrappedMovedErr := fmt.Errorf("hook wrapper: %w", movedErr)
|
||||
cmd2.SetErr(wrappedMovedErr)
|
||||
|
||||
// Verify we can still detect and extract address from MOVED error
|
||||
addr, ok := redis.IsMovedError(cmd2.Err())
|
||||
if !ok {
|
||||
t.Errorf("IsMovedError failed to detect wrapped error set via SetErr: %v", cmd2.Err())
|
||||
}
|
||||
if addr != "127.0.0.1:6381" {
|
||||
t.Errorf("Address extraction failed: got %q, want %q", addr, "127.0.0.1:6381")
|
||||
}
|
||||
|
||||
// Test with READONLY error
|
||||
cmd3 := redis.NewStatusCmd(testCtx, "SET", "key", "value")
|
||||
readonlyErr := proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica"))
|
||||
wrappedReadonlyErr := fmt.Errorf("custom error wrapper: %w", readonlyErr)
|
||||
cmd3.SetErr(wrappedReadonlyErr)
|
||||
|
||||
// Verify we can still detect the READONLY error through the wrapper
|
||||
if !redis.IsReadOnlyError(cmd3.Err()) {
|
||||
t.Errorf("IsReadOnlyError failed to detect wrapped error set via SetErr: %v", cmd3.Err())
|
||||
}
|
||||
|
||||
// Verify the error message contains both the wrapper and original error
|
||||
errMsg := cmd3.Err().Error()
|
||||
if !contains(errMsg, "custom error wrapper") {
|
||||
t.Errorf("Error message missing wrapper context: %v", errMsg)
|
||||
}
|
||||
if !contains(errMsg, "READONLY") {
|
||||
t.Errorf("Error message missing original error: %v", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// AppError is a custom error type for testing
|
||||
type AppError struct {
|
||||
Code string
|
||||
Message string
|
||||
RequestID string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *AppError) Error() string {
|
||||
return fmt.Sprintf("[%s] %s (request_id=%s): %v", e.Code, e.Message, e.RequestID, e.Err)
|
||||
}
|
||||
|
||||
// Unwrap implements the error unwrapping interface - this is critical for errors.As() to work
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// TestCustomErrorTypeWrapping tests that users can wrap Redis errors in their own custom error types
|
||||
// and still have typed error detection work correctly
|
||||
func TestCustomErrorTypeWrapping(t *testing.T) {
|
||||
testCtx := context.Background()
|
||||
|
||||
// Test 1: Wrap LOADING error in custom type
|
||||
cmd1 := redis.NewStatusCmd(testCtx, "GET", "key")
|
||||
loadingErr := proto.ParseErrorReply([]byte("-LOADING Redis is loading the dataset in memory"))
|
||||
customErr1 := &AppError{
|
||||
Code: "REDIS_ERROR",
|
||||
Message: "Database operation failed",
|
||||
RequestID: "req-12345",
|
||||
Err: loadingErr,
|
||||
}
|
||||
cmd1.SetErr(customErr1)
|
||||
|
||||
// Verify typed error detection works through custom error type
|
||||
if !redis.IsLoadingError(cmd1.Err()) {
|
||||
t.Errorf("IsLoadingError failed to detect error wrapped in custom type: %v", cmd1.Err())
|
||||
}
|
||||
|
||||
// Verify error message contains custom context
|
||||
errMsg := cmd1.Err().Error()
|
||||
if !contains(errMsg, "REDIS_ERROR") || !contains(errMsg, "req-12345") {
|
||||
t.Errorf("Error message missing custom error context: %v", errMsg)
|
||||
}
|
||||
|
||||
// Test 2: Wrap MOVED error in custom type
|
||||
cmd2 := redis.NewStatusCmd(testCtx, "GET", "key")
|
||||
movedErr := proto.ParseErrorReply([]byte("-MOVED 3999 127.0.0.1:6381"))
|
||||
customErr2 := &AppError{
|
||||
Code: "CLUSTER_REDIRECT",
|
||||
Message: "Key moved to different node",
|
||||
RequestID: "req-67890",
|
||||
Err: movedErr,
|
||||
}
|
||||
cmd2.SetErr(customErr2)
|
||||
|
||||
// Verify address extraction works through custom error type
|
||||
addr, ok := redis.IsMovedError(cmd2.Err())
|
||||
if !ok {
|
||||
t.Errorf("IsMovedError failed to detect error wrapped in custom type: %v", cmd2.Err())
|
||||
}
|
||||
if addr != "127.0.0.1:6381" {
|
||||
t.Errorf("Address extraction failed: got %q, want %q", addr, "127.0.0.1:6381")
|
||||
}
|
||||
|
||||
// Test 3: Multiple levels of wrapping (custom type + fmt.Errorf)
|
||||
cmd3 := redis.NewStatusCmd(testCtx, "SET", "key", "value")
|
||||
readonlyErr := proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica"))
|
||||
customErr3 := &AppError{
|
||||
Code: "WRITE_ERROR",
|
||||
Message: "Write operation failed",
|
||||
RequestID: "req-11111",
|
||||
Err: readonlyErr,
|
||||
}
|
||||
// Wrap the custom error again with fmt.Errorf
|
||||
doubleWrapped := fmt.Errorf("hook context: %w", customErr3)
|
||||
cmd3.SetErr(doubleWrapped)
|
||||
|
||||
// Verify typed error detection works through multiple levels of wrapping
|
||||
if !redis.IsReadOnlyError(cmd3.Err()) {
|
||||
t.Errorf("IsReadOnlyError failed to detect error wrapped in custom type + fmt.Errorf: %v", cmd3.Err())
|
||||
}
|
||||
|
||||
// Verify we can unwrap to get the custom error
|
||||
var appErr *AppError
|
||||
if !errors.As(cmd3.Err(), &appErr) {
|
||||
t.Errorf("errors.As failed to extract custom error type from wrapped error")
|
||||
} else {
|
||||
if appErr.Code != "WRITE_ERROR" || appErr.RequestID != "req-11111" {
|
||||
t.Errorf("Custom error fields incorrect: Code=%s, RequestID=%s", appErr.Code, appErr.RequestID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeoutErrorWrapping tests that timeout errors work correctly when wrapped
|
||||
func TestTimeoutErrorWrapping(t *testing.T) {
|
||||
// Test 1: Wrapped timeoutError interface
|
||||
t.Run("Wrapped timeoutError with Timeout()=true", func(t *testing.T) {
|
||||
timeoutErr := &testTimeoutError{timeout: true, msg: "i/o timeout"}
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", timeoutErr)
|
||||
doubleWrappedErr := fmt.Errorf("another wrapper: %w", wrappedErr)
|
||||
|
||||
// Should NOT retry when retryTimeout=false
|
||||
if redis.ShouldRetry(doubleWrappedErr, false) {
|
||||
t.Errorf("Should not retry timeout error when retryTimeout=false")
|
||||
}
|
||||
|
||||
// Should retry when retryTimeout=true
|
||||
if !redis.ShouldRetry(doubleWrappedErr, true) {
|
||||
t.Errorf("Should retry timeout error when retryTimeout=true")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Wrapped timeoutError with Timeout()=false
|
||||
t.Run("Wrapped timeoutError with Timeout()=false", func(t *testing.T) {
|
||||
timeoutErr := &testTimeoutError{timeout: false, msg: "connection error"}
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", timeoutErr)
|
||||
|
||||
// Should always retry when Timeout()=false
|
||||
if !redis.ShouldRetry(wrappedErr, false) {
|
||||
t.Errorf("Should retry non-timeout error even when retryTimeout=false")
|
||||
}
|
||||
if !redis.ShouldRetry(wrappedErr, true) {
|
||||
t.Errorf("Should retry non-timeout error when retryTimeout=true")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Wrapped net.Error with Timeout()=true
|
||||
t.Run("Wrapped net.Error", func(t *testing.T) {
|
||||
netErr := &testNetError{timeout: true, temporary: true, msg: "network timeout"}
|
||||
wrappedErr := fmt.Errorf("hook context: %w", netErr)
|
||||
|
||||
// Should respect retryTimeout parameter
|
||||
if redis.ShouldRetry(wrappedErr, false) {
|
||||
t.Errorf("Should not retry network timeout when retryTimeout=false")
|
||||
}
|
||||
if !redis.ShouldRetry(wrappedErr, true) {
|
||||
t.Errorf("Should retry network timeout when retryTimeout=true")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Multiple levels of wrapping
|
||||
t.Run("Multiple levels of wrapping", func(t *testing.T) {
|
||||
timeoutErr := &testTimeoutError{timeout: true, msg: "timeout"}
|
||||
customErr := &AppError{
|
||||
Code: "TIMEOUT_ERROR",
|
||||
Message: "Operation timed out",
|
||||
RequestID: "req-timeout-123",
|
||||
Err: timeoutErr,
|
||||
}
|
||||
wrappedErr := fmt.Errorf("hook wrapper: %w", customErr)
|
||||
|
||||
// Should still detect timeout through multiple wrappers
|
||||
if redis.ShouldRetry(wrappedErr, false) {
|
||||
t.Errorf("Should not retry timeout through custom error when retryTimeout=false")
|
||||
}
|
||||
if !redis.ShouldRetry(wrappedErr, true) {
|
||||
t.Errorf("Should retry timeout through custom error when retryTimeout=true")
|
||||
}
|
||||
|
||||
// Should be able to extract custom error
|
||||
var appErr *AppError
|
||||
if !errors.As(wrappedErr, &appErr) {
|
||||
t.Errorf("Should be able to extract AppError from wrapped error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testTimeoutError implements the timeoutError interface for testing
|
||||
type testTimeoutError struct {
|
||||
timeout bool
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *testTimeoutError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
func (e *testTimeoutError) Timeout() bool {
|
||||
return e.timeout
|
||||
}
|
||||
|
||||
// testNetError implements net.Error for testing
|
||||
type testNetError struct {
|
||||
timeout bool
|
||||
temporary bool
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *testNetError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
func (e *testNetError) Timeout() bool {
|
||||
return e.timeout
|
||||
}
|
||||
|
||||
func (e *testNetError) Temporary() bool {
|
||||
return e.temporary
|
||||
}
|
||||
|
||||
// TestContextErrorWrapping tests that context errors work correctly when wrapped
|
||||
func TestContextErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped context.Canceled", func(t *testing.T) {
|
||||
wrappedErr := fmt.Errorf("operation failed: %w", context.Canceled)
|
||||
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
|
||||
|
||||
// Should NOT retry
|
||||
if redis.ShouldRetry(doubleWrappedErr, false) {
|
||||
t.Errorf("Should not retry wrapped context.Canceled")
|
||||
}
|
||||
if redis.ShouldRetry(doubleWrappedErr, true) {
|
||||
t.Errorf("Should not retry wrapped context.Canceled even with retryTimeout=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrapped context.DeadlineExceeded", func(t *testing.T) {
|
||||
wrappedErr := fmt.Errorf("timeout: %w", context.DeadlineExceeded)
|
||||
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
|
||||
|
||||
// Should NOT retry
|
||||
if redis.ShouldRetry(doubleWrappedErr, false) {
|
||||
t.Errorf("Should not retry wrapped context.DeadlineExceeded")
|
||||
}
|
||||
if redis.ShouldRetry(doubleWrappedErr, true) {
|
||||
t.Errorf("Should not retry wrapped context.DeadlineExceeded even with retryTimeout=true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIOErrorWrapping tests that io errors work correctly when wrapped
|
||||
func TestIOErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped io.EOF", func(t *testing.T) {
|
||||
wrappedErr := fmt.Errorf("read failed: %w", io.EOF)
|
||||
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
|
||||
|
||||
// Should retry
|
||||
if !redis.ShouldRetry(doubleWrappedErr, false) {
|
||||
t.Errorf("Should retry wrapped io.EOF")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrapped io.ErrUnexpectedEOF", func(t *testing.T) {
|
||||
wrappedErr := fmt.Errorf("read failed: %w", io.ErrUnexpectedEOF)
|
||||
|
||||
// Should retry
|
||||
if !redis.ShouldRetry(wrappedErr, false) {
|
||||
t.Errorf("Should retry wrapped io.ErrUnexpectedEOF")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPoolErrorWrapping tests that pool errors work correctly when wrapped
|
||||
func TestPoolErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped pool.ErrPoolTimeout", func(t *testing.T) {
|
||||
wrappedErr := fmt.Errorf("connection failed: %w", redis.ErrPoolTimeout)
|
||||
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
|
||||
|
||||
// Should retry
|
||||
if !redis.ShouldRetry(doubleWrappedErr, false) {
|
||||
t.Errorf("Should retry wrapped pool.ErrPoolTimeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRedisErrorWrapping tests that RedisError detection works with wrapped errors
|
||||
func TestRedisErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped proto.RedisError", func(t *testing.T) {
|
||||
redisErr := proto.RedisError("ERR something went wrong")
|
||||
wrappedErr := fmt.Errorf("command failed: %w", redisErr)
|
||||
doubleWrappedErr := fmt.Errorf("hook wrapper: %w", wrappedErr)
|
||||
|
||||
// Create a command and set the wrapped error
|
||||
cmd := redis.NewStatusCmd(context.Background(), "GET", "key")
|
||||
cmd.SetErr(doubleWrappedErr)
|
||||
|
||||
// The error should still be recognized as a Redis error
|
||||
// This is tested indirectly through the typed error system
|
||||
if !strings.Contains(cmd.Err().Error(), "ERR something went wrong") {
|
||||
t.Errorf("Error message not preserved through wrapping")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
|
||||
func TestAuthErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped NOAUTH error", func(t *testing.T) {
|
||||
// Create an auth error
|
||||
authErr := proto.NewAuthError("NOAUTH Authentication required")
|
||||
|
||||
// Wrap it
|
||||
wrappedErr := fmt.Errorf("hook: %w", authErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsAuthError(wrappedErr) {
|
||||
t.Errorf("IsAuthError should detect wrapped NOAUTH error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrapped WRONGPASS error", func(t *testing.T) {
|
||||
// Create an auth error
|
||||
authErr := proto.NewAuthError("WRONGPASS invalid username-password pair")
|
||||
|
||||
// Wrap it multiple times
|
||||
wrappedErr := fmt.Errorf("connection error: %w", authErr)
|
||||
doubleWrappedErr := fmt.Errorf("client error: %w", wrappedErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsAuthError(doubleWrappedErr) {
|
||||
t.Errorf("IsAuthError should detect double-wrapped WRONGPASS error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrapped unauthenticated error", func(t *testing.T) {
|
||||
// Create an auth error
|
||||
authErr := proto.NewAuthError("ERR unauthenticated")
|
||||
|
||||
// Wrap it
|
||||
wrappedErr := fmt.Errorf("hook: %w", authErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsAuthError(wrappedErr) {
|
||||
t.Errorf("IsAuthError should detect wrapped unauthenticated error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped NOPERM error", func(t *testing.T) {
|
||||
// Create a permission error
|
||||
permErr := proto.NewPermissionError("NOPERM this user has no permissions to run the 'flushdb' command")
|
||||
|
||||
// Wrap it
|
||||
wrappedErr := fmt.Errorf("hook: %w", permErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsPermissionError(wrappedErr) {
|
||||
t.Errorf("IsPermissionError should detect wrapped NOPERM error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecAbortErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped EXECABORT error", func(t *testing.T) {
|
||||
// Create an EXECABORT error
|
||||
execAbortErr := proto.NewExecAbortError("EXECABORT Transaction discarded because of previous errors")
|
||||
|
||||
// Wrap it
|
||||
wrappedErr := fmt.Errorf("hook: %w", execAbortErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsExecAbortError(wrappedErr) {
|
||||
t.Errorf("IsExecAbortError should detect wrapped EXECABORT error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOOMErrorWrapping(t *testing.T) {
|
||||
t.Run("Wrapped OOM error", func(t *testing.T) {
|
||||
// Create an OOM error
|
||||
oomErr := proto.NewOOMError("OOM command not allowed when used memory > 'maxmemory'")
|
||||
|
||||
// Wrap it
|
||||
wrappedErr := fmt.Errorf("hook: %w", oomErr)
|
||||
|
||||
// Should still be detected
|
||||
if !redis.IsOOMError(wrappedErr) {
|
||||
t.Errorf("IsOOMError should detect wrapped OOM error")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user