Skip to content

Commit

Permalink
Add support for grpc error details
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
dmcgowan committed Jun 8, 2024
1 parent 038bb7b commit 2fd184b
Show file tree
Hide file tree
Showing 4 changed files with 295 additions and 59 deletions.
182 changes: 142 additions & 40 deletions errgrpc/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
}

Expand All @@ -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()
}
101 changes: 101 additions & 0 deletions errgrpc/grpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)))
}
13 changes: 8 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading

0 comments on commit 2fd184b

Please sign in to comment.