grpc: introduce ErrRetriesExhausted to wrap retry failures (#8894)
Fixes: https://github.com/grpc/grpc-go/issues/7023
This change wraps RPC errors when the maximum retry limit is reached.
RELEASE NOTES:
* grpc: Introduce `ErrRetriesExhausted` to wrap RPC failures after
maximum retries are reached.
diff --git a/status/status_ext_test.go b/status/status_ext_test.go
index 5ff7693..2598ca6 100644
--- a/status/status_ext_test.go
+++ b/status/status_ext_test.go
@@ -21,6 +21,7 @@
import (
"context"
"errors"
+ "fmt"
"reflect"
"strings"
"testing"
@@ -259,3 +260,58 @@
}
}
}
+
+func (s) TestFromError_Wrapped(t *testing.T) {
+ base := status.New(codes.Canceled, "inner canceled")
+ sWithDetails, err := base.WithDetails(&testpb.Empty{})
+ if err != nil {
+ t.Fatalf("WithDetails failed: %v", err)
+ }
+ innerErr := sWithDetails.Err()
+ mustStatus := func(message string) *status.Status {
+ st, err := status.New(codes.Canceled, message).WithDetails(&testpb.Empty{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return st
+ }
+
+ testCases := []struct {
+ name string
+ err error
+ wantStatus *status.Status
+ }{
+ {
+ name: "direct_error",
+ err: innerErr,
+ wantStatus: mustStatus("inner canceled"),
+ },
+ {
+ name: "wrapped_error",
+ err: fmt.Errorf("wrapped: %w", innerErr),
+ wantStatus: mustStatus("wrapped: rpc error: code = Canceled desc = inner canceled"),
+ },
+ {
+ name: "double_wrapped_error",
+ err: fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", innerErr)),
+ wantStatus: mustStatus("outer: inner: rpc error: code = Canceled desc = inner canceled"),
+ },
+ {
+ name: "double_wrapped_single_errorf",
+ err: fmt.Errorf("error: %w: %w", errors.New("test error"), innerErr),
+ wantStatus: mustStatus("error: test error: rpc error: code = Canceled desc = inner canceled"),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := status.FromError(tc.err)
+ if !ok {
+ t.Fatalf("status.FromError(%v) returned false; want true", tc.err)
+ }
+ if diff := cmp.Diff(tc.wantStatus, got, protocmp.Transform(), cmp.AllowUnexported(status.Status{})); diff != "" {
+ t.Fatalf("status.FromError(%v) got unexpected output, diff (-want +got):\n%s", tc.err, diff)
+ }
+ })
+ }
+}
diff --git a/stream.go b/stream.go
index eedb5f9..046f549 100644
--- a/stream.go
+++ b/stream.go
@@ -21,6 +21,7 @@
import (
"context"
"errors"
+ "fmt"
"io"
"math"
rand "math/rand/v2"
@@ -147,6 +148,15 @@
RecvMsg(m any) error
}
+// ErrRetriesExhausted is returned when an RPC exceeds its configured maximum
+// number of retry attempts.
+//
+// # Experimental
+//
+// Notice: This type is EXPERIMENTAL and may be changed or removed in a
+// later release.
+var ErrRetriesExhausted = errors.New("max retry attempts exhausted")
+
// NewStream creates a new Stream for the client side. This is typically
// called by generated code. ctx is used for the lifetime of the stream.
//
@@ -749,7 +759,7 @@
return false, err
}
if cs.numRetries+1 >= rp.MaxAttempts {
- return false, err
+ return false, fmt.Errorf("stopped after %d attempts: %w: %w", cs.numRetries+1, ErrRetriesExhausted, err)
}
var dur time.Duration
diff --git a/test/retry_test.go b/test/retry_test.go
index 72d79a5..af97be2 100644
--- a/test/retry_test.go
+++ b/test/retry_test.go
@@ -20,6 +20,7 @@
import (
"context"
+ "errors"
"fmt"
"io"
"net"
@@ -275,15 +276,15 @@
}
cErr := func(c codes.Code) clientOp {
return func(stream testgrpc.TestService_FullDuplexCallClient) error {
- want := status.New(c, "this is a test error").Err()
- if c == codes.OK {
- want = io.EOF
- }
res, err := stream.Recv()
- if res != nil ||
- ((err == nil) != (want == nil)) ||
- (want != nil && err.Error() != want.Error()) {
- return fmt.Errorf("client: Recv() = %v, %v; want <nil>, %v", res, err, want)
+ var gotCode codes.Code
+ if err == io.EOF {
+ gotCode = codes.OK
+ } else {
+ gotCode = status.Code(err)
+ }
+ if res != nil || gotCode != c {
+ return fmt.Errorf("client: Recv() = %v, %v; want <nil>, %v", res, err, c)
}
return nil
}
@@ -550,7 +551,10 @@
t.Fatalf("client: Recv() = %s, %v; want <nil>, error", got, err)
} else if status.Code(err) != codes.Unavailable {
t.Fatalf("client: Recv() = _, %v; want _, Unavailable", err)
+ } else if !errors.Is(err, grpc.ErrRetriesExhausted) {
+ t.Fatalf("want: ErrRetriesExhausted, got: %v", err)
}
+
if streamCallCount != tc.expectedAttempts {
t.Fatalf("stream expectedAttempts = %v; want %v", streamCallCount, tc.expectedAttempts)
}
@@ -816,3 +820,81 @@
stream1.Recv()
stream1.Send(&testpb.StreamingOutputCallRequest{})
}
+
+func (s) TestNoRetry(t *testing.T) {
+ scJSON := `{
+ "methodConfig": [{
+ "name": [{"service": "grpc.testing.TestService"}],
+ "retryPolicy": {
+ "MaxAttempts": 4,
+ "InitialBackoff": ".01s",
+ "MaxBackoff": ".01s",
+ "BackoffMultiplier": 1.0,
+ "RetryableStatusCodes": [ "UNAVAILABLE" ]
+ }
+ }]}`
+ tests := []struct {
+ name string
+ dialOpts []grpc.DialOption
+ }{
+ {
+ name: "disabled",
+ dialOpts: []grpc.DialOption{
+ grpc.WithDefaultServiceConfig(scJSON),
+ grpc.WithDisableRetry(),
+ },
+ },
+ {
+ name: "not_configured",
+ dialOpts: []grpc.DialOption{},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ss := &stubserver.StubServer{
+ FullDuplexCallF: func(testgrpc.TestService_FullDuplexCallServer) error {
+ return status.New(codes.Unavailable, "retryable error").Err()
+ },
+ EmptyCallF: func(context.Context, *testpb.Empty) (r *testpb.Empty, err error) {
+ return nil, status.New(codes.Unavailable, "retryable error").Err()
+ },
+ }
+ if err := ss.Start([]grpc.ServerOption{}, tc.dialOpts...); err != nil {
+ t.Fatalf("Error starting endpoint server: %v", err)
+ }
+ defer ss.Stop()
+
+ ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
+ defer cancel()
+
+ // Test streaming RPC
+ stream, err := ss.Client.FullDuplexCall(ctx)
+ if err != nil {
+ t.Fatalf("Error while creating stream: %v", err)
+ }
+ _, err = stream.Recv()
+ if err == nil {
+ t.Fatal("stream.Recv() succeeded when expected to fail")
+ }
+ if status.Code(err) != codes.Unavailable {
+ t.Fatalf("client: Recv() = _, %v; want _, Unavailable", err)
+ }
+ if errors.Is(err, grpc.ErrRetriesExhausted) {
+ t.Fatalf("client: Recv() error matches ErrRetriesExhausted, want not match")
+ }
+
+ // Test unary RPC
+ _, err = ss.Client.EmptyCall(ctx, &testpb.Empty{})
+ if err == nil {
+ t.Fatal("EmptyCall() succeeded when expected to fail")
+ }
+ if status.Code(err) != codes.Unavailable {
+ t.Fatalf("client: EmptyCall() = _, %v; want _, Unavailable", err)
+ }
+ if errors.Is(err, grpc.ErrRetriesExhausted) {
+ t.Fatalf("client: EmptyCall() error matches ErrRetriesExhausted, want not match")
+ }
+ })
+ }
+}