From 2fd184b6822970d7fb7f30cacdbc194085c4ceb5 Mon Sep 17 00:00:00 2001 From: Derek McGowan Date: Thu, 6 Jun 2024 16:01:23 -0700 Subject: [PATCH] Add support for grpc error details When multiple errors are given, use details to encode errors into the grpc status and decode details back into errors. Signed-off-by: Derek McGowan --- errgrpc/grpc.go | 182 +++++++++++++++++++++++++++++++++---------- errgrpc/grpc_test.go | 101 ++++++++++++++++++++++++ go.mod | 13 ++-- go.sum | 58 ++++++++++---- 4 files changed, 295 insertions(+), 59 deletions(-) diff --git a/errgrpc/grpc.go b/errgrpc/grpc.go index e9cfce7..57b1be1 100644 --- a/errgrpc/grpc.go +++ b/errgrpc/grpc.go @@ -23,12 +23,20 @@ package errgrpc import ( "context" + "errors" "fmt" + "reflect" "strconv" "strings" + spb "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/protoadapt" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/containerd/typeurl/v2" "github.com/containerd/errdefs" "github.com/containerd/errdefs/internal/cause" @@ -47,47 +55,121 @@ func ToGRPC(err error) error { return nil } - if isGRPCError(err) { + if _, ok := status.FromError(err); ok { // error has already been mapped to grpc return err } + s, extra := findStatus(err, "") + if s != nil { + var details []protoadapt.MessageV1 + + for _, e := range extra { + // Do not double encode proto messages, otherwise use Any + if pm, ok := e.(protoadapt.MessageV1); ok { + details = append(details, pm) + } else if pm, ok := e.(proto.Message); ok { + details = append(details, protoadapt.MessageV1Of(pm)) + } else { + + if reflect.TypeOf(e).Kind() == reflect.Ptr { + a, aerr := typeurl.MarshalAny(e) + if aerr == nil { + details = append(details, &anypb.Any{ + TypeUrl: a.GetTypeUrl(), + Value: a.GetValue(), + }) + continue + } + } + if gs, ok := status.FromError(ToGRPC(e)); ok { + details = append(details, gs.Proto()) + } + // TODO: Else include unknown extra error type + } + } + + if len(details) > 0 { + if ds, _ := s.WithDetails(details...); ds != nil { + s = ds + } + } + err = s.Err() + } + + return err +} + +// findStatus finds the first error which matches a GRPC status and returns +// any extra +func findStatus(err error, msg string) (*status.Status, []error) { + switch uerr := err.(type) { + case interface{ Unwrap() error }: + if msg == "" { + // preserve wrap message + msg = err.Error() + } + return findStatus(uerr.Unwrap(), msg) + case interface{ Unwrap() []error }: + var extra []error + var status *status.Status + for _, e := range uerr.Unwrap() { + // NOTE: Multi errors do not preserve message when created fmt.Errorf, + // Document this and suggest use of errors.Join with individual messages + // for each error. + s, errs := findStatus(e, msg) + if s != nil && status == nil { + status = s + extra = append(extra, errs...) + } else { + extra = append(extra, e) + } + } + return status, extra + } + if msg == "" { + msg = err.Error() + } + + return getStatus(err, msg), nil +} + +func getStatus(err error, msg string) *status.Status { switch { case errdefs.IsInvalidArgument(err): - return status.Error(codes.InvalidArgument, err.Error()) + return status.New(codes.InvalidArgument, msg) case errdefs.IsNotFound(err): - return status.Error(codes.NotFound, err.Error()) + return status.New(codes.NotFound, msg) case errdefs.IsAlreadyExists(err): - return status.Error(codes.AlreadyExists, err.Error()) + return status.New(codes.AlreadyExists, msg) case errdefs.IsFailedPrecondition(err) || errdefs.IsConflict(err) || errdefs.IsNotModified(err): - return status.Error(codes.FailedPrecondition, err.Error()) + return status.New(codes.FailedPrecondition, msg) case errdefs.IsUnavailable(err): - return status.Error(codes.Unavailable, err.Error()) + return status.New(codes.Unavailable, msg) case errdefs.IsNotImplemented(err): - return status.Error(codes.Unimplemented, err.Error()) + return status.New(codes.Unimplemented, msg) case errdefs.IsCanceled(err): - return status.Error(codes.Canceled, err.Error()) + return status.New(codes.Canceled, msg) case errdefs.IsDeadlineExceeded(err): - return status.Error(codes.DeadlineExceeded, err.Error()) + return status.New(codes.DeadlineExceeded, msg) case errdefs.IsUnauthorized(err): - return status.Error(codes.Unauthenticated, err.Error()) + return status.New(codes.Unauthenticated, msg) case errdefs.IsPermissionDenied(err): - return status.Error(codes.PermissionDenied, err.Error()) + return status.New(codes.PermissionDenied, msg) case errdefs.IsInternal(err): - return status.Error(codes.Internal, err.Error()) + return status.New(codes.Internal, msg) case errdefs.IsDataLoss(err): - return status.Error(codes.DataLoss, err.Error()) + return status.New(codes.DataLoss, msg) case errdefs.IsAborted(err): - return status.Error(codes.Aborted, err.Error()) + return status.New(codes.Aborted, msg) case errdefs.IsOutOfRange(err): - return status.Error(codes.OutOfRange, err.Error()) + return status.New(codes.OutOfRange, msg) case errdefs.IsResourceExhausted(err): - return status.Error(codes.ResourceExhausted, err.Error()) + return status.New(codes.ResourceExhausted, msg) case errdefs.IsUnknown(err): - return status.Error(codes.Unknown, err.Error()) + return status.New(codes.Unknown, msg) } - - return err + return nil } // ToGRPCf maps the error to grpc error codes, assembling the formatting string @@ -104,11 +186,25 @@ func ToNative(err error) error { return nil } - desc := errDesc(err) + s, isGRPC := status.FromError(err) + + var ( + desc string + code codes.Code + ) + + if isGRPC { + desc = s.Message() + code = s.Code() + + } else { + desc = err.Error() + code = codes.Unknown + } var cls error // divide these into error classes, becomes the cause - switch code(err) { + switch code { case codes.InvalidArgument: cls = errdefs.ErrInvalidArgument case codes.AlreadyExists: @@ -163,6 +259,31 @@ func ToNative(err error) error { err = cls } + if isGRPC { + errs := []error{err} + for _, a := range s.Details() { + if s, ok := a.(*spb.Status); ok { + errs = append(errs, ToNative(status.ErrorProto(s))) + } else if derr, ok := a.(error); ok { + errs = append(errs, derr) + } else if dany, ok := a.(typeurl.Any); ok { + i, uerr := typeurl.UnmarshalAny(dany) + if uerr == nil { + if derr, ok = i.(error); ok { + errs = append(errs, derr) + } + // TODO: Wrap unknown type in error for visibility + } + // TODO: Wrap unregistered type in error for visibility + } + // TODO: Wrap unknown type in error for visibility + + } + if len(errs) > 1 { + err = errors.Join(errs...) + } + } + return err } @@ -179,22 +300,3 @@ func rebaseMessage(cls error, desc string) string { return strings.TrimSuffix(desc, ": "+clss) } - -func isGRPCError(err error) bool { - _, ok := status.FromError(err) - return ok -} - -func code(err error) codes.Code { - if s, ok := status.FromError(err); ok { - return s.Code() - } - return codes.Unknown -} - -func errDesc(err error) string { - if s, ok := status.FromError(err); ok { - return s.Message() - } - return err.Error() -} diff --git a/errgrpc/grpc_test.go b/errgrpc/grpc_test.go index 7a3778c..1a4cf34 100644 --- a/errgrpc/grpc_test.go +++ b/errgrpc/grpc_test.go @@ -20,11 +20,14 @@ import ( "context" "errors" "fmt" + "strings" "testing" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/containerd/typeurl/v2" + "github.com/containerd/errdefs" "github.com/containerd/errdefs/errhttp" "github.com/containerd/errdefs/internal/cause" @@ -172,3 +175,101 @@ func TestGRPCRoundTrip(t *testing.T) { }) } } + +type TestError struct { + Value string `json:"value"` +} + +func (*TestError) Error() string { + return "test error" +} + +func TestGRPCCustomDetails(t *testing.T) { + typeurl.Register(&TestError{}, t.Name()) + expected := &TestError{ + Value: "test 1", + } + + err := errors.Join(errdefs.ErrInternal, expected) + gerr := ToGRPC(err) + + s, ok := status.FromError(gerr) + if !ok { + t.Fatalf("Not GRPC error: %v", gerr) + } + if s.Code() != codes.Internal { + t.Fatalf("Unexpectd GRPC code %v, expected %v", s.Code(), codes.Internal) + } + + nerr := ToNative(gerr) + if !errors.Is(nerr, errdefs.ErrInternal) { + t.Fatalf("Expected internal error type, got %v", nerr) + } + if !errdefs.IsInternal(err) { + t.Fatalf("Expected internal error type, got %v", nerr) + } + terr := &TestError{} + if !errors.As(nerr, &terr) { + t.Fatalf("TestError not preserved, got %v", nerr) + } else if terr.Value != expected.Value { + t.Fatalf("Value not preserved, got %v", terr.Value) + } +} + +func TestGRPCMultiError(t *testing.T) { + err := errors.Join(errdefs.ErrPermissionDenied, errdefs.ErrDataLoss, errdefs.ErrConflict, fmt.Errorf("Was not changed at all!: %w", errdefs.ErrNotModified)) + + checkError := func(err error) { + t.Helper() + if !errors.Is(err, errdefs.ErrPermissionDenied) { + t.Fatal("Not permission denied") + } + if !errors.Is(err, errdefs.ErrDataLoss) { + t.Fatal("Not data loss") + } + if !errors.Is(err, errdefs.ErrConflict) { + t.Fatal("Not conflict") + } + if !errors.Is(err, errdefs.ErrNotModified) { + t.Fatal("Not not modified") + } + if errors.Is(err, errdefs.ErrFailedPrecondition) { + t.Fatal("Should not be failed precondition") + } + if !strings.Contains(err.Error(), "Was not changed at all!") { + t.Fatalf("Not modified error message missing from:\n%v", err) + } + } + checkError(err) + + terr := ToNative(ToGRPC(err)) + + checkError(terr) + + // Try again with decoded error + checkError(ToNative(ToGRPC(terr))) +} + +func TestGRPCNestedError(t *testing.T) { + multiErr := errors.Join(fmt.Errorf("First error: %w", errdefs.ErrNotFound), fmt.Errorf("Second error: %w", errdefs.ErrResourceExhausted)) + + checkError := func(err error) { + t.Helper() + if !errors.Is(err, errdefs.ErrNotFound) { + t.Fatal("Not not found") + } + if !errors.Is(err, errdefs.ErrResourceExhausted) { + t.Fatal("Not resource exhausted") + } + if errors.Is(err, errdefs.ErrFailedPrecondition) { + t.Fatal("Should not be failed precondition") + } + } + checkError(multiErr) + + werr := fmt.Errorf("Wrapping the error: %w", multiErr) + + checkError(werr) + + checkError(ToNative(ToGRPC(werr))) +} diff --git a/go.mod b/go.mod index d6525c7..a564385 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,14 @@ module github.com/containerd/errdefs go 1.20 -require google.golang.org/grpc v1.58.3 +require ( + github.com/containerd/typeurl/v2 v2.1.1 + google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de + google.golang.org/grpc v1.63.2 + google.golang.org/protobuf v1.33.0 +) require ( - github.com/golang/protobuf v1.5.3 // indirect - golang.org/x/sys v0.13.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 // indirect - google.golang.org/protobuf v1.31.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + golang.org/x/sys v0.17.0 // indirect ) diff --git a/go.sum b/go.sum index 4e7237e..4d6d05c 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,48 @@ +github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= +github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= -google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= -google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=