fix(error): make wrapped dial tcp error redirectable (#3799) (#3810)

Co-authored-by: Vladislav Isakov <[email protected]>
This commit is contained in:
Vladislav
2026-05-18 15:02:36 +03:00
committed by GitHub
co-authored by Vladislav Isakov
parent ad7fabb336
commit c615d30b7d
2 changed files with 62 additions and 6 deletions
+8 -6
View File
@@ -91,6 +91,14 @@ func shouldRetry(err error, retryTimeout bool) bool {
return true
}
// Dial errors mean TCP connection was never established — safe to retry even
// when wrapped inside context.DeadlineExceeded (from DialTimeout context).
// Must be checked before the context error check below.
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return true
}
// Check for context errors (works with wrapped errors)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
@@ -105,12 +113,6 @@ func shouldRetry(err error, retryTimeout bool) bool {
// Check for timeout errors (works with wrapped errors)
if isTimeout, hasTimeoutFlag := isTimeoutError(err); isTimeout {
if hasTimeoutFlag {
// A dial error means the TCP connection was never established and the
// command was never sent to the server, so retry is always safe
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return true
}
return retryTimeout
}
return true
+54
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
@@ -638,6 +639,59 @@ func TestContextErrorWrapping(t *testing.T) {
})
}
// TestDialTimeoutShouldRetry tests that dial errors wrapped in context.DeadlineExceeded
// (as produced by pool.go's context.WithTimeout(ctx, DialTimeout)) are retried.
func TestDialTimeoutShouldRetry(t *testing.T) {
makeDialOpError := func(inner error) *net.OpError {
return &net.OpError{Op: "dial", Net: "tcp", Err: inner}
}
t.Run("bare dial OpError retries", func(t *testing.T) {
err := makeDialOpError(&net.AddrError{Err: "connection refused", Addr: "127.0.0.1:6379"})
if !redis.ShouldRetry(err, false) {
t.Errorf("bare dial OpError should retry")
}
})
t.Run("dial OpError wrapped in context.DeadlineExceeded retries", func(t *testing.T) {
// Simulates pool.go wraps net.OpError as DeadlineExceeded.
dialErr := makeDialOpError(context.DeadlineExceeded)
wrappedErr := fmt.Errorf("dial tcp: %w", dialErr)
if !redis.ShouldRetry(wrappedErr, false) {
t.Errorf("dial OpError wrapped in DeadlineExceeded should retry, got false")
}
if !redis.ShouldRetry(wrappedErr, true) {
t.Errorf("dial OpError wrapped in DeadlineExceeded should retry, got false")
}
})
t.Run("plain context.DeadlineExceeded does not retry", func(t *testing.T) {
err := fmt.Errorf("op failed: %w", context.DeadlineExceeded)
if redis.ShouldRetry(err, false) {
t.Errorf("plain DeadlineExceeded should not retry")
}
if redis.ShouldRetry(err, true) {
t.Errorf("plain DeadlineExceeded should not retry even with retryTimeout=true")
}
})
t.Run("plain context.Canceled does not retry", func(t *testing.T) {
err := fmt.Errorf("op failed: %w", context.Canceled)
if redis.ShouldRetry(err, false) {
t.Errorf("plain context.Canceled should not retry")
}
})
t.Run("non-dial OpError does not bypass DeadlineExceeded check", func(t *testing.T) {
// Op="read" wrapped in DeadlineExceeded should NOT retry (user context expired)
readErr := &net.OpError{Op: "read", Net: "tcp", Err: context.DeadlineExceeded}
wrappedErr := fmt.Errorf("read: %w", readErr)
if redis.ShouldRetry(wrappedErr, false) {
t.Errorf("non-dial OpError with DeadlineExceeded should not retry")
}
})
}
// TestIOErrorWrapping tests that io errors work correctly when wrapped
func TestIOErrorWrapping(t *testing.T) {
t.Run("Wrapped io.EOF", func(t *testing.T) {