Skip to content

Commit c7e33ef

Browse files
committed
extgrpc: add support for automatic en/decoding of gRPC Status
This patch adds support for automatic en/decoding of gRPC `Status` errors, enabled by importing the `extgrpc` package. It supports statuses generated both by the standard `google.golang.org/grpc/status` and the gogoproto-based `github.com/gogo/status` packages. Encoded statuses are always represented as `gogo/status`-based `Status` Protobufs, since the `EncodedError` type is a gogoproto type using an `Any` field for structured errors, and only gogoproto types can be placed here since standard Protobufs are not registered in the gogoproto type registry. We lock the `gogo/googleapis` package to 1.2.0 to use gogoproto 1.2-compatible Protobufs required by CRDB, these have been verified to not be vulnerable to the "skippy pb" bug. Note that to preserve error details, gogo-based `Status` objects with gogo-based details must be used, otherwise details may not be preserved. This is for similar reasons as outlined above, and is a limitation in the gRPC package -- see code comments for further details. A CRDB linter rule forbidding use of `Status.WithDetails()` was submitted in cockroachdb/cockroach#61617.
1 parent 25e283f commit c7e33ef

File tree

4 files changed

+246
-21
lines changed

4 files changed

+246
-21
lines changed

extgrpc/ext_grpc.go

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@
1212
// implied. See the License for the specific language governing
1313
// permissions and limitations under the License.
1414

15+
// This package provides support for gRPC error handling. It has
16+
// two main features:
17+
//
18+
// 1. Automatic en/decoding of gRPC Status errors via EncodeError and
19+
// DecodeError, enabled by importing the package. Supports both the
20+
// standard google.golang.org/grpc/status and the gogoproto-compatible
21+
// github.com/gogo/status packages.
22+
//
23+
// 2. Wrapping arbitrary errors with a gRPC status code via WrapWithGrpcCode()
24+
// and GetGrpcCode(). There is also a gRPC middleware in middleware/grpc
25+
// that will automatically do this (un)wrapping.
26+
//
27+
// Note that it is not possible to mix standard Protobuf and gogoproto
28+
// status details via Status.WithDetails(). All details must be standard
29+
// Protobufs with the standard grpc/status package, and all details must
30+
// be gogoproto Protobufs with the gogo/status package. This is caused by
31+
// WithDetails() and Details() immediately (un)marshalling the detail as
32+
// an Any field, and since gogoproto types are not registered with the standard
33+
// Protobuf registry (and vice versa) they cannot be unmarshalled.
34+
//
35+
// Furthermore, since we have to encode all errors as gogoproto Status
36+
// messages to place them in EncodedError (again because of an Any field),
37+
// only gogoproto status details can be preserved across EncodeError().
38+
// See also: https://github.com/cockroachdb/errors/issues/63
39+
1540
package extgrpc
1641

1742
import (
@@ -22,28 +47,28 @@ import (
2247
"github.com/cockroachdb/errors/errbase"
2348
"github.com/cockroachdb/errors/markers"
2449
"github.com/cockroachdb/redact"
50+
gogorpc "github.com/gogo/googleapis/google/rpc"
2551
"github.com/gogo/protobuf/proto"
52+
gogostatus "github.com/gogo/status"
2653
"google.golang.org/grpc/codes"
54+
grpcstatus "google.golang.org/grpc/status"
2755
)
2856

29-
// This file demonstrates how to add a wrapper type not otherwise
30-
// known to the rest of the library.
31-
32-
// withGrpcCode is our wrapper type.
57+
// withGrpcCode wraps an error with a gRPC status code.
3358
type withGrpcCode struct {
3459
cause error
3560
code codes.Code
3661
}
3762

38-
// WrapWithGrpcCode adds a Grpc code to an existing error.
63+
// WrapWithGrpcCode wraps an error with a gRPC status code.
3964
func WrapWithGrpcCode(err error, code codes.Code) error {
4065
if err == nil {
4166
return nil
4267
}
4368
return &withGrpcCode{cause: err, code: code}
4469
}
4570

46-
// GetGrpcCode retrieves the Grpc code from a stack of causes.
71+
// GetGrpcCode retrieves the gRPC code from a stack of causes.
4772
func GetGrpcCode(err error) codes.Code {
4873
if err == nil {
4974
return codes.OK
@@ -70,7 +95,7 @@ func (w *withGrpcCode) Unwrap() error { return w.cause }
7095
func (w *withGrpcCode) Format(s fmt.State, verb rune) { errors.FormatError(w, s, verb) }
7196

7297
// SafeFormatter implements errors.SafeFormatter.
73-
// Note: see the documentat ion of errbase.SafeFormatter for details
98+
// Note: see the documentation of errbase.SafeFormatter for details
7499
// on how to implement this. In particular beware of not emitting
75100
// unsafe strings.
76101
func (w *withGrpcCode) SafeFormatError(p errors.Printer) (next error) {
@@ -96,7 +121,68 @@ func decodeWithGrpcCode(
96121
return &withGrpcCode{cause: cause, code: codes.Code(wp.Code)}
97122
}
98123

124+
// encodeGrpcStatus takes an error generated by a standard gRPC Status and
125+
// converts it into a GoGo Protobuf representation from
126+
// github.com/gogo/googleapis/google/rpc.
127+
//
128+
// This is necessary since EncodedError uses an Any field for structured errors,
129+
// and thus can only contain Protobufs that have been registered with the GoGo
130+
// Protobuf type registry -- the standard gRPC Status type is not a GoGo
131+
// Protobuf, and is therefore not registered with it and cannot be decoded by
132+
// DecodeError().
133+
//
134+
// Also note that in order to use error details, the input type must be a
135+
// gogoproto Status from github.com/gogo/status, not from the standard gRPC
136+
// Status, and all details must be gogoproto types. The reasons for this
137+
// are the same as for the Any field issue mentioned above.
138+
func encodeGrpcStatus(_ context.Context, err error) (string, []string, proto.Message) {
139+
s := gogostatus.Convert(err)
140+
// If there are known safe details, return them.
141+
details := []string{}
142+
for _, detail := range s.Details() {
143+
if safe, ok := detail.(errbase.SafeDetailer); ok {
144+
details = append(details, safe.SafeDetails()...)
145+
}
146+
}
147+
return s.Message(), details, s.Proto()
148+
}
149+
150+
// decodeGrpcStatus is the inverse of encodeGrpcStatus. It takes a gogoproto
151+
// Status as input, and converts it into a standard gRPC Status error.
152+
func decodeGrpcStatus(
153+
ctx context.Context, msg string, details []string, payload proto.Message,
154+
) error {
155+
return grpcstatus.Convert(decodeGoGoStatus(ctx, msg, details, payload)).Err()
156+
}
157+
158+
// encodeGoGoStatus encodes a GoGo Status error. It calls encodeGrpcStatus, since
159+
// it can handle both kinds.
160+
func encodeGoGoStatus(ctx context.Context, err error) (string, []string, proto.Message) {
161+
return encodeGrpcStatus(ctx, err)
162+
}
163+
164+
// decodeGoGoStatus is similar to decodeGrpcStatus, but decodes into a gogo
165+
// Status error instead of a gRPC Status error. It is used when the original
166+
// error was a gogo Status error rather than a gRPC Status error.
167+
func decodeGoGoStatus(_ context.Context, _ string, _ []string, payload proto.Message) error {
168+
s, ok := payload.(*gogorpc.Status)
169+
if !ok {
170+
// If input type was unexpected (shouldn't happen), we just return nil
171+
// which will cause DecodeError() to return an opaqueLeaf.
172+
return nil
173+
}
174+
return gogostatus.ErrorProto(s)
175+
}
176+
99177
func init() {
178+
grpcError := grpcstatus.Error(codes.Unknown, "")
179+
errbase.RegisterLeafEncoder(errbase.GetTypeKey(grpcError), encodeGrpcStatus)
180+
errbase.RegisterLeafDecoder(errbase.GetTypeKey(grpcError), decodeGrpcStatus)
181+
182+
gogoError := gogostatus.Error(codes.Unknown, "")
183+
errbase.RegisterLeafEncoder(errbase.GetTypeKey(gogoError), encodeGoGoStatus)
184+
errbase.RegisterLeafDecoder(errbase.GetTypeKey(gogoError), decodeGoGoStatus)
185+
100186
errbase.RegisterWrapperEncoder(errbase.GetTypeKey((*withGrpcCode)(nil)), encodeWithGrpcCode)
101187
errbase.RegisterWrapperDecoder(errbase.GetTypeKey((*withGrpcCode)(nil)), decodeWithGrpcCode)
102188
}

extgrpc/ext_grpc_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ import (
2020
"testing"
2121

2222
"github.com/cockroachdb/errors"
23+
"github.com/cockroachdb/errors/errbase"
24+
"github.com/cockroachdb/errors/errorspb"
2325
"github.com/cockroachdb/errors/extgrpc"
2426
"github.com/cockroachdb/errors/testutils"
27+
"github.com/gogo/protobuf/proto"
28+
gogostatus "github.com/gogo/status"
29+
"github.com/stretchr/testify/require"
2530
"google.golang.org/grpc/codes"
31+
grpcstatus "google.golang.org/grpc/status"
32+
"google.golang.org/protobuf/runtime/protoiface"
2633
)
2734

2835
func TestGrpc(t *testing.T) {
@@ -57,3 +64,137 @@ Error types: (1) *extgrpc.withGrpcCode (2) *errors.errorString`)
5764
var noErr error
5865
tt.Assert(extgrpc.GetGrpcCode(noErr) == codes.OK)
5966
}
67+
68+
// dummyProto is a dummy Protobuf message which satisfies the proto.Message
69+
// interface but is not registered with either the standard Protobuf or GoGo
70+
// Protobuf type registries.
71+
type dummyProto struct {
72+
value string
73+
}
74+
75+
func (p *dummyProto) Reset() {}
76+
func (p *dummyProto) String() string { return "" }
77+
func (p *dummyProto) ProtoMessage() {}
78+
79+
// statusIface is a thin interface for common gRPC and gogo Status functionality.
80+
type statusIface interface {
81+
Code() codes.Code
82+
Message() string
83+
Details() []interface{}
84+
Err() error
85+
}
86+
87+
func TestEncodeDecodeStatus(t *testing.T) {
88+
testcases := []struct {
89+
desc string
90+
makeStatus func(*testing.T, codes.Code, string, []proto.Message) statusIface
91+
fromError func(err error) statusIface
92+
expectDetails []interface{} // nil elements signify errors
93+
}{
94+
{
95+
desc: "gogo status",
96+
makeStatus: func(t *testing.T, code codes.Code, msg string, details []proto.Message) statusIface {
97+
s, err := gogostatus.New(code, msg).WithDetails(details...)
98+
require.NoError(t, err)
99+
return s
100+
},
101+
fromError: func(err error) statusIface {
102+
return gogostatus.Convert(err)
103+
},
104+
expectDetails: []interface{}{
105+
nil, // Protobuf decode fails
106+
&errorspb.StringsPayload{Details: []string{"foo", "bar"}}, // gogoproto succeeds
107+
nil, // dummy decode fails
108+
},
109+
},
110+
{
111+
desc: "grpc status",
112+
makeStatus: func(t *testing.T, code codes.Code, msg string, details []proto.Message) statusIface {
113+
s := grpcstatus.New(code, msg)
114+
for _, detail := range details {
115+
var err error
116+
s, err = s.WithDetails(protoiface.MessageV1(detail))
117+
require.NoError(t, err)
118+
}
119+
return s
120+
},
121+
fromError: func(err error) statusIface {
122+
return grpcstatus.Convert(err)
123+
},
124+
expectDetails: []interface{}{
125+
grpcstatus.New(codes.Internal, "status").Proto(), // Protobuf succeeds
126+
nil, // gogoproto decode fails
127+
nil, // dummy decode fails
128+
},
129+
},
130+
}
131+
for _, tc := range testcases {
132+
tc := tc
133+
t.Run(tc.desc, func(t *testing.T) {
134+
ctx := context.Background()
135+
136+
// Create a Status, using statusIface to support gRPC and gogo variants.
137+
status := tc.makeStatus(t, codes.NotFound, "message", []proto.Message{
138+
grpcstatus.New(codes.Internal, "status").Proto(), // standard Protobuf
139+
&errorspb.StringsPayload{Details: []string{"foo", "bar"}}, // GoGo Protobuf
140+
&dummyProto{value: "dummy"}, // unregistered
141+
})
142+
require.Equal(t, codes.NotFound, status.Code())
143+
require.Equal(t, "message", status.Message())
144+
145+
// Check the details. This varies by implementation, since different
146+
// Protobuf decoders are used -- gRPC Status can only decode
147+
// standard Protobufs, while gogo Status can only decode gogoproto
148+
// Protobufs.
149+
statusDetails := status.Details()
150+
require.Equal(t, len(tc.expectDetails), len(statusDetails), "detail mismatch")
151+
for i, expectDetail := range tc.expectDetails {
152+
if expectDetail == nil {
153+
require.Implements(t, (*error)(nil), statusDetails[i], "detail %v", i)
154+
} else {
155+
require.Equal(t, expectDetail, statusDetails[i], "detail %v", i)
156+
}
157+
}
158+
159+
// Encode the error and check some fields.
160+
encodedError := errbase.EncodeError(ctx, status.Err())
161+
leaf := encodedError.GetLeaf()
162+
require.NotNil(t, leaf, "expected leaf")
163+
require.Equal(t, status.Message(), leaf.Message)
164+
require.Equal(t, []string{}, leaf.Details.ReportablePayload) // test this?
165+
require.NotNil(t, leaf.Details.FullDetails, "expected full details")
166+
require.Nil(t, encodedError.GetWrapper(), "unexpected wrapper")
167+
168+
// Marshal and unmarshal the error, checking that
169+
// it equals the encoded error.
170+
marshaledError, err := encodedError.Marshal()
171+
require.NoError(t, err)
172+
require.NotEmpty(t, marshaledError)
173+
174+
unmarshaledError := errorspb.EncodedError{}
175+
err = proto.Unmarshal(marshaledError, &unmarshaledError)
176+
require.NoError(t, err)
177+
require.True(t, proto.Equal(&encodedError, &unmarshaledError),
178+
"unmarshaled Protobuf differs")
179+
180+
// Decode the error.
181+
decodedError := errbase.DecodeError(ctx, unmarshaledError)
182+
require.Equal(t, status.Err().Error(), decodedError.Error())
183+
184+
// Convert the error into a status, and check its properties.
185+
decodedStatus := tc.fromError(decodedError)
186+
require.Equal(t, status.Code(), decodedStatus.Code())
187+
require.Equal(t, status.Message(), decodedStatus.Message())
188+
189+
decodedDetails := decodedStatus.Details()
190+
require.Equal(t, len(tc.expectDetails), len(decodedDetails), "detail mismatch")
191+
for i, expectDetail := range tc.expectDetails {
192+
if expectDetail == nil {
193+
require.Implements(t, (*error)(nil), decodedDetails[i], "detail %v", i)
194+
} else {
195+
require.Equal(t, expectDetail, decodedDetails[i], "detail %v", i)
196+
}
197+
}
198+
})
199+
}
200+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ require (
77
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f
88
github.com/cockroachdb/redact v1.0.8
99
github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2
10+
github.com/gogo/googleapis v1.2.0 // gogoproto 1.2-compatible, for CRDB
1011
github.com/gogo/protobuf v1.3.2
1112
github.com/gogo/status v1.1.0
1213
github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb
1314
github.com/kr/pretty v0.1.0
1415
github.com/pkg/errors v0.9.1
16+
github.com/stretchr/testify v1.4.0
1517
google.golang.org/grpc v1.29.1
18+
google.golang.org/protobuf v1.23.0
1619
)

0 commit comments

Comments
 (0)