Skip to content

Commit 4edf494

Browse files
authored
fix(retry): Add retry mechanism for NOREPLICAS error (#3647)
1 parent e4965ea commit 4edf494

File tree

5 files changed

+78
-12
lines changed

5 files changed

+78
-12
lines changed

error.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ func shouldRetry(err error, retryTimeout bool) bool {
124124
if proto.IsTryAgainError(err) {
125125
return true
126126
}
127+
if proto.IsNoReplicasError(err) {
128+
return true
129+
}
127130

128131
// Fallback to string checking for backward compatibility with plain errors
129132
s := err.Error()
@@ -145,6 +148,9 @@ func shouldRetry(err error, retryTimeout bool) bool {
145148
if strings.HasPrefix(s, "MASTERDOWN ") {
146149
return true
147150
}
151+
if strings.HasPrefix(s, "NOREPLICAS ") {
152+
return true
153+
}
148154

149155
return false
150156
}
@@ -342,6 +348,14 @@ func IsOOMError(err error) bool {
342348
return proto.IsOOMError(err)
343349
}
344350

351+
// IsNoReplicasError checks if an error is a Redis NOREPLICAS error, even if wrapped.
352+
// NOREPLICAS errors occur when not enough replicas acknowledge a write operation.
353+
// This typically happens with WAIT/WAITAOF commands or CLUSTER SETSLOT with synchronous
354+
// replication when the required number of replicas cannot confirm the write within the timeout.
355+
func IsNoReplicasError(err error) bool {
356+
return proto.IsNoReplicasError(err)
357+
}
358+
345359
//------------------------------------------------------------------------------
346360

347361
type timeoutError interface {

error_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ var _ = Describe("error", func() {
4545
proto.ParseErrorReply([]byte("-READONLY You can't write against a read only replica")): true,
4646
proto.ParseErrorReply([]byte("-CLUSTERDOWN The cluster is down")): true,
4747
proto.ParseErrorReply([]byte("-TRYAGAIN Command cannot be processed, please try again")): true,
48-
proto.ParseErrorReply([]byte("-ERR other")): false,
48+
proto.ParseErrorReply([]byte("-NOREPLICAS Not enough good replicas to write")): true,
49+
proto.ParseErrorReply([]byte("-ERR other")): false,
4950
}
5051

5152
for err, expected := range data {

error_wrapping_test.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,10 @@ func TestErrorWrappingInHookScenario(t *testing.T) {
239239
// TestShouldRetryWithTypedErrors tests that shouldRetry works with typed errors
240240
func TestShouldRetryWithTypedErrors(t *testing.T) {
241241
tests := []struct {
242-
name string
243-
errorMsg string
244-
shouldRetry bool
245-
retryTimeout bool
242+
name string
243+
errorMsg string
244+
shouldRetry bool
245+
retryTimeout bool
246246
}{
247247
{
248248
name: "LOADING error should retry",
@@ -280,6 +280,12 @@ func TestShouldRetryWithTypedErrors(t *testing.T) {
280280
shouldRetry: true,
281281
retryTimeout: false,
282282
},
283+
{
284+
name: "NOREPLICAS error should retry",
285+
errorMsg: "NOREPLICAS Not enough good replicas to write",
286+
shouldRetry: true,
287+
retryTimeout: false,
288+
},
283289
}
284290

285291
for _, tt := range tests {

internal/proto/redis_errors.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,25 @@ func NewOOMError(msg string) *OOMError {
212212
return &OOMError{msg: msg}
213213
}
214214

215+
// NoReplicasError is returned when not enough replicas acknowledge a write.
216+
// This error occurs when using WAIT/WAITAOF commands or CLUSTER SETSLOT with
217+
// synchronous replication, and the required number of replicas cannot confirm
218+
// the write within the timeout period.
219+
type NoReplicasError struct {
220+
msg string
221+
}
222+
223+
func (e *NoReplicasError) Error() string {
224+
return e.msg
225+
}
226+
227+
func (e *NoReplicasError) RedisError() {}
228+
229+
// NewNoReplicasError creates a new NoReplicasError with the given message.
230+
func NewNoReplicasError(msg string) *NoReplicasError {
231+
return &NoReplicasError{msg: msg}
232+
}
233+
215234
// parseTypedRedisError parses a Redis error message and returns a typed error if applicable.
216235
// This function maintains backward compatibility by keeping the same error messages.
217236
func parseTypedRedisError(msg string) error {
@@ -235,6 +254,8 @@ func parseTypedRedisError(msg string) error {
235254
return NewTryAgainError(msg)
236255
case strings.HasPrefix(msg, "MASTERDOWN "):
237256
return NewMasterDownError(msg)
257+
case strings.HasPrefix(msg, "NOREPLICAS "):
258+
return NewNoReplicasError(msg)
238259
case msg == "ERR max number of clients reached":
239260
return NewMaxClientsError(msg)
240261
case strings.HasPrefix(msg, "NOAUTH "), strings.HasPrefix(msg, "WRONGPASS "), strings.Contains(msg, "unauthenticated"):
@@ -486,3 +507,21 @@ func IsOOMError(err error) bool {
486507
// Fallback to string checking for backward compatibility
487508
return strings.HasPrefix(err.Error(), "OOM ")
488509
}
510+
511+
// IsNoReplicasError checks if an error is a NoReplicasError, even if wrapped.
512+
func IsNoReplicasError(err error) bool {
513+
if err == nil {
514+
return false
515+
}
516+
var noReplicasErr *NoReplicasError
517+
if errors.As(err, &noReplicasErr) {
518+
return true
519+
}
520+
// Check if wrapped error is a RedisError with NOREPLICAS prefix
521+
var redisErr RedisError
522+
if errors.As(err, &redisErr) && strings.HasPrefix(redisErr.Error(), "NOREPLICAS ") {
523+
return true
524+
}
525+
// Fallback to string checking for backward compatibility
526+
return strings.HasPrefix(err.Error(), "NOREPLICAS ")
527+
}

internal/proto/redis_errors_test.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import (
99
// TestTypedRedisErrors tests that typed Redis errors are created correctly
1010
func TestTypedRedisErrors(t *testing.T) {
1111
tests := []struct {
12-
name string
13-
errorMsg string
14-
expectedType interface{}
15-
expectedMsg string
16-
checkFunc func(error) bool
17-
extractAddr func(error) string
12+
name string
13+
errorMsg string
14+
expectedType interface{}
15+
expectedMsg string
16+
checkFunc func(error) bool
17+
extractAddr func(error) string
1818
}{
1919
{
2020
name: "LOADING error",
@@ -132,6 +132,13 @@ func TestTypedRedisErrors(t *testing.T) {
132132
expectedMsg: "OOM command not allowed when used memory > 'maxmemory'",
133133
checkFunc: IsOOMError,
134134
},
135+
{
136+
name: "NOREPLICAS error",
137+
errorMsg: "NOREPLICAS Not enough good replicas to write",
138+
expectedType: &NoReplicasError{},
139+
expectedMsg: "NOREPLICAS Not enough good replicas to write",
140+
checkFunc: IsNoReplicasError,
141+
},
135142
}
136143

137144
for _, tt := range tests {
@@ -389,4 +396,3 @@ func TestBackwardCompatibility(t *testing.T) {
389396
})
390397
}
391398
}
392-

0 commit comments

Comments
 (0)