diff --git a/errgrpc/grpc.go b/errgrpc/grpc.go new file mode 100644 index 0000000..0472c28 --- /dev/null +++ b/errgrpc/grpc.go @@ -0,0 +1,353 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package errgrpc provides utility functions for translating errors to +// and from a gRPC context. +// +// The functions ToGRPC and ToNative can be used to map server-side and +// client-side errors to the correct types. +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" + "github.com/containerd/errdefs/internal/types" +) + +// ToGRPC will attempt to map the error into a grpc error, from the error types +// defined in the the errdefs package and attempign to preserve the original +// description. Any type which does not resolve to a defined error type will +// be assigned the unknown error code. +// +// Further information may be extracted from certain errors depending on their +// type. The grpc error details will be used to attempt to preserve as much of +// the error structures and types as possible. +// +// Errors which can be marshaled using protobuf or typeurl will be considered +// for including as GRPC error details. +// Additionally, use the following interfaces in errors to preserve custom types: +// +// WrapError(error) error - Used to wrap the previous error +// JoinErrors(...error) error - Used to join all previous errors +// CollapseError() - Used for errors which carry information but +// should not have their error message shown. +func ToGRPC(err error) error { + if err == nil { + return nil + } + + if _, ok := status.FromError(err); ok { + // error has already been mapped to grpc + return err + } + st := statusFromError(err) + if st != nil { + if details := errorDetails(err, false); len(details) > 0 { + if ds, _ := st.WithDetails(details...); ds != nil { + st = ds + } + } + err = st.Err() + } + return err +} + +func statusFromError(err error) *status.Status { + switch errdefs.Resolve(err) { + case errdefs.ErrInvalidArgument: + return status.New(codes.InvalidArgument, err.Error()) + case errdefs.ErrNotFound: + return status.New(codes.NotFound, err.Error()) + case errdefs.ErrAlreadyExists: + return status.New(codes.AlreadyExists, err.Error()) + case errdefs.ErrPermissionDenied: + return status.New(codes.PermissionDenied, err.Error()) + case errdefs.ErrResourceExhausted: + return status.New(codes.ResourceExhausted, err.Error()) + case errdefs.ErrFailedPrecondition, errdefs.ErrConflict, errdefs.ErrNotModified: + return status.New(codes.FailedPrecondition, err.Error()) + case errdefs.ErrAborted: + return status.New(codes.Aborted, err.Error()) + case errdefs.ErrOutOfRange: + return status.New(codes.OutOfRange, err.Error()) + case errdefs.ErrNotImplemented: + return status.New(codes.Unimplemented, err.Error()) + case errdefs.ErrInternal: + return status.New(codes.Internal, err.Error()) + case errdefs.ErrUnavailable: + return status.New(codes.Unavailable, err.Error()) + case errdefs.ErrDataLoss: + return status.New(codes.DataLoss, err.Error()) + case errdefs.ErrUnauthenticated: + return status.New(codes.Unauthenticated, err.Error()) + case context.DeadlineExceeded: + return status.New(codes.DeadlineExceeded, err.Error()) + case context.Canceled: + return status.New(codes.Canceled, err.Error()) + case errdefs.ErrUnknown: + return status.New(codes.Unknown, err.Error()) + } + return nil +} + +// errorDetails returns an array of errors which make up the provided error. +// If firstIncluded is true, then all encodable errors will be used, otherwise +// the first error in an error list will be not be used, to account for the +// the base status error which details are added to via wrap or join. +// +// The errors are ordered in way that they can be applied in order by either +// wrapping or joining the errors to recreate an error with the same structure +// when `WrapError` and `JoinErrors` interfaces are used. +// +// The intent is that when re-applying the errors to create a single error, the +// results of calls to `Error()`, `errors.Is`, `errors.As`, and "%+v" formatting +// is the same as the original error. +func errorDetails(err error, firstIncluded bool) []protoadapt.MessageV1 { + switch uerr := err.(type) { + case interface{ Unwrap() error }: + details := errorDetails(uerr.Unwrap(), firstIncluded) + + // If the type is able to wrap, then include if proto + if _, ok := err.(interface{ WrapError(error) error }); ok { + // Get proto message + if protoErr := toProtoMessage(err); protoErr != nil { + details = append(details, protoErr) + } + } + + return details + case interface{ Unwrap() []error }: + var details []protoadapt.MessageV1 + for i, e := range uerr.Unwrap() { + details = append(details, errorDetails(e, firstIncluded || i > 0)...) + } + + if _, ok := err.(interface{ JoinErrors(...error) error }); ok { + // Get proto message + if protoErr := toProtoMessage(err); protoErr != nil { + details = append(details, protoErr) + } + } + return details + } + + if firstIncluded { + if protoErr := toProtoMessage(err); protoErr != nil { + return []protoadapt.MessageV1{protoErr} + } + if gs, ok := status.FromError(ToGRPC(err)); ok { + return []protoadapt.MessageV1{gs.Proto()} + } + // TODO: Else include unknown extra error type? + } + + return nil +} + +func toProtoMessage(err error) protoadapt.MessageV1 { + // Do not double encode proto messages, otherwise use Any + if pm, ok := err.(protoadapt.MessageV1); ok { + return pm + } + if pm, ok := err.(proto.Message); ok { + return protoadapt.MessageV1Of(pm) + } + + if reflect.TypeOf(err).Kind() == reflect.Ptr { + a, aerr := typeurl.MarshalAny(err) + if aerr == nil { + return &anypb.Any{ + TypeUrl: a.GetTypeUrl(), + Value: a.GetValue(), + } + } + } + return nil +} + +// ToGRPCf maps the error to grpc error codes, assembling the formatting string +// and combining it with the target error string. +// +// This is equivalent to grpc.ToGRPC(fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)) +func ToGRPCf(err error, format string, args ...interface{}) error { + return ToGRPC(fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)) +} + +// ToNative returns the underlying error from a grpc service based on the grpc +// error code. The grpc details are used to add wrap the error in more context +// or support multiple errors. +func ToNative(err error) error { + if err == nil { + return nil + } + + 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 { + case codes.InvalidArgument: + cls = errdefs.ErrInvalidArgument + case codes.AlreadyExists: + cls = errdefs.ErrAlreadyExists + case codes.NotFound: + cls = errdefs.ErrNotFound + case codes.Unavailable: + cls = errdefs.ErrUnavailable + case codes.FailedPrecondition: + // TODO: Has suffix is not sufficient for conflict and not modified + // Message should start with ": " or be at beginning of a line + // Message should end with ": " or be at the end of a line + // Compile a regex + if desc == errdefs.ErrConflict.Error() || strings.HasSuffix(desc, ": "+errdefs.ErrConflict.Error()) { + cls = errdefs.ErrConflict + } else if desc == errdefs.ErrNotModified.Error() || strings.HasSuffix(desc, ": "+errdefs.ErrNotModified.Error()) { + cls = errdefs.ErrNotModified + } else { + cls = errdefs.ErrFailedPrecondition + } + case codes.Unimplemented: + cls = errdefs.ErrNotImplemented + case codes.Canceled: + cls = context.Canceled + case codes.DeadlineExceeded: + cls = context.DeadlineExceeded + case codes.Aborted: + cls = errdefs.ErrAborted + case codes.Unauthenticated: + cls = errdefs.ErrUnauthenticated + case codes.PermissionDenied: + cls = errdefs.ErrPermissionDenied + case codes.Internal: + cls = errdefs.ErrInternal + case codes.DataLoss: + cls = errdefs.ErrDataLoss + case codes.OutOfRange: + cls = errdefs.ErrOutOfRange + case codes.ResourceExhausted: + cls = errdefs.ErrResourceExhausted + default: + if idx := strings.LastIndex(desc, cause.UnexpectedStatusPrefix); idx > 0 { + if status, uerr := strconv.Atoi(desc[idx+len(cause.UnexpectedStatusPrefix):]); uerr == nil && status >= 200 && status < 600 { + cls = cause.ErrUnexpectedStatus{Status: status} + } + } + if cls == nil { + cls = errdefs.ErrUnknown + } + } + + msg := rebaseMessage(cls, desc) + if msg == "" { + err = cls + } else if msg != desc { + err = fmt.Errorf("%s: %w", msg, cls) + } else if wm, ok := cls.(interface{ WithMessage(string) error }); ok { + err = wm.WithMessage(msg) + } else { + err = fmt.Errorf("%s: %w", msg, cls) + } + + if isGRPC { + errs := []error{err} + for _, a := range s.Details() { + var derr error + + // First decode error if needed + if s, ok := a.(*spb.Status); ok { + derr = ToNative(status.ErrorProto(s)) + } else if e, ok := a.(error); ok { + derr = e + } else if dany, ok := a.(typeurl.Any); ok { + i, uerr := typeurl.UnmarshalAny(dany) + if uerr == nil { + if e, ok = i.(error); ok { + derr = e + } else { + derr = fmt.Errorf("non-error unmarshalled detail: %v", i) + } + } else { + derr = fmt.Errorf("error of type %q with failure to unmarshal: %v", dany.GetTypeUrl(), uerr) + } + } else { + derr = fmt.Errorf("non-error detail: %v", a) + } + + switch werr := derr.(type) { + case interface{ WrapError(error) error }: + errs[len(errs)-1] = werr.WrapError(errs[len(errs)-1]) + case interface{ JoinErrors(...error) error }: + // TODO: Consider whether this should support joining a subset + errs[0] = werr.JoinErrors(errs...) + case interface{ CollapseError() }: + errs[len(errs)-1] = types.CollapsedError(errs[len(errs)-1], derr) + default: + errs = append(errs, derr) + } + + } + if len(errs) > 1 { + err = errors.Join(errs...) + } else { + err = errs[0] + } + } + + return err +} + +// rebaseMessage removes the repeats for an error at the end of an error +// string. This will happen when taking an error over grpc then remapping it. +// +// Effectively, we just remove the string of cls from the end of err if it +// appears there. +func rebaseMessage(cls error, desc string) string { + clss := cls.Error() + if desc == clss { + return "" + } + + return strings.TrimSuffix(desc, ": "+clss) +} diff --git a/errgrpc/grpc_test.go b/errgrpc/grpc_test.go new file mode 100644 index 0000000..bc7790d --- /dev/null +++ b/errgrpc/grpc_test.go @@ -0,0 +1,276 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package errgrpc + +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" +) + +func TestGRPCNilInput(t *testing.T) { + if err := ToGRPC(nil); err != nil { + t.Fatalf("Expected nil error, got %v", err) + } + if err := ToNative(nil); err != nil { + t.Fatalf("Expected nil error, got %v", err) + } +} + +func TestGRPCRoundTrip(t *testing.T) { + errShouldLeaveAlone := errors.New("unknown to package") + + for _, testcase := range []struct { + input error + cause error + str string + }{ + { + input: errdefs.ErrInvalidArgument, + cause: errdefs.ErrInvalidArgument, + }, + { + input: errdefs.ErrAlreadyExists, + cause: errdefs.ErrAlreadyExists, + }, + { + input: errdefs.ErrNotFound, + cause: errdefs.ErrNotFound, + }, + { + input: errdefs.ErrUnavailable, + cause: errdefs.ErrUnavailable, + }, + { + input: errdefs.ErrNotImplemented, + cause: errdefs.ErrNotImplemented, + }, + { + input: errdefs.ErrUnauthenticated, + cause: errdefs.ErrUnauthenticated, + }, + { + input: errdefs.ErrPermissionDenied, + cause: errdefs.ErrPermissionDenied, + }, + { + input: errdefs.ErrInternal, + cause: errdefs.ErrInternal, + }, + { + input: errdefs.ErrDataLoss, + cause: errdefs.ErrDataLoss, + }, + { + input: errdefs.ErrAborted, + cause: errdefs.ErrAborted, + }, + { + input: errdefs.ErrOutOfRange, + cause: errdefs.ErrOutOfRange, + }, + { + input: errdefs.ErrResourceExhausted, + cause: errdefs.ErrResourceExhausted, + }, + { + input: errdefs.ErrUnknown, + cause: errdefs.ErrUnknown, + }, + //nolint:dupword + { + input: fmt.Errorf("test test test: %w", errdefs.ErrFailedPrecondition), + cause: errdefs.ErrFailedPrecondition, + str: "test test test: failed precondition", + }, + { + // Currently failing + input: status.Errorf(codes.Unavailable, "should be not available"), + cause: errdefs.ErrUnavailable, + str: "should be not available", + }, + { + input: errShouldLeaveAlone, + cause: errdefs.ErrUnknown, + str: errShouldLeaveAlone.Error(), + }, + { + input: context.Canceled, + cause: context.Canceled, + str: "context canceled", + }, + { + input: fmt.Errorf("this is a test cancel: %w", context.Canceled), + cause: context.Canceled, + str: "this is a test cancel: context canceled", + }, + { + input: context.DeadlineExceeded, + cause: context.DeadlineExceeded, + str: "context deadline exceeded", + }, + { + input: fmt.Errorf("this is a test deadline exceeded: %w", context.DeadlineExceeded), + cause: context.DeadlineExceeded, + str: "this is a test deadline exceeded: context deadline exceeded", + }, + { + input: fmt.Errorf("something conflicted: %w", errdefs.ErrConflict), + cause: errdefs.ErrConflict, + str: "something conflicted: conflict", + }, + { + input: fmt.Errorf("everything is the same: %w", errdefs.ErrNotModified), + cause: errdefs.ErrNotModified, + str: "everything is the same: not modified", + }, + { + input: fmt.Errorf("odd HTTP response: %w", errhttp.ToNative(418)), + cause: cause.ErrUnexpectedStatus{Status: 418}, + str: "odd HTTP response: unexpected status 418", + }, + } { + t.Run(testcase.input.Error(), func(t *testing.T) { + t.Logf("input: %v", testcase.input) + gerr := ToGRPC(testcase.input) + t.Logf("grpc: %v", gerr) + ferr := ToNative(gerr) + t.Logf("recovered: %v", ferr) + + if !errors.Is(ferr, testcase.cause) { + t.Fatalf("unexpected cause: !errors.Is(%v, %v)", ferr, testcase.cause) + } + + expected := testcase.str + if expected == "" { + expected = testcase.cause.Error() + } + if ferr.Error() != expected { + t.Fatalf("unexpected string: %q != %q", ferr.Error(), expected) + } + }) + } +} + +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/errhttp/http.go b/errhttp/http.go new file mode 100644 index 0000000..dfdf5c8 --- /dev/null +++ b/errhttp/http.go @@ -0,0 +1,96 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package errhttp provides utility functions for translating errors to +// and from a HTTP context. +// +// The functions ToHTTP and ToNative can be used to map server-side and +// client-side errors to the correct types. +package errhttp + +import ( + "errors" + "net/http" + + "github.com/containerd/errdefs" + "github.com/containerd/errdefs/internal/cause" +) + +// ToHTTP returns the best status code for the given error +func ToHTTP(err error) int { + switch { + case errdefs.IsNotFound(err): + return http.StatusNotFound + case errdefs.IsInvalidArgument(err): + return http.StatusBadRequest + case errdefs.IsConflict(err): + return http.StatusConflict + case errdefs.IsNotModified(err): + return http.StatusNotModified + case errdefs.IsFailedPrecondition(err): + return http.StatusPreconditionFailed + case errdefs.IsUnauthorized(err): + return http.StatusUnauthorized + case errdefs.IsPermissionDenied(err): + return http.StatusForbidden + case errdefs.IsResourceExhausted(err): + return http.StatusTooManyRequests + case errdefs.IsInternal(err): + return http.StatusInternalServerError + case errdefs.IsNotImplemented(err): + return http.StatusNotImplemented + case errdefs.IsUnavailable(err): + return http.StatusServiceUnavailable + case errdefs.IsUnknown(err): + var unexpected cause.ErrUnexpectedStatus + if errors.As(err, &unexpected) && unexpected.Status >= 200 && unexpected.Status < 600 { + return unexpected.Status + } + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +} + +// ToNative returns the error best matching the HTTP status code +func ToNative(statusCode int) error { + switch statusCode { + case http.StatusNotFound: + return errdefs.ErrNotFound + case http.StatusBadRequest: + return errdefs.ErrInvalidArgument + case http.StatusConflict: + return errdefs.ErrConflict + case http.StatusPreconditionFailed: + return errdefs.ErrFailedPrecondition + case http.StatusUnauthorized: + return errdefs.ErrUnauthenticated + case http.StatusForbidden: + return errdefs.ErrPermissionDenied + case http.StatusNotModified: + return errdefs.ErrNotModified + case http.StatusTooManyRequests: + return errdefs.ErrResourceExhausted + case http.StatusInternalServerError: + return errdefs.ErrInternal + case http.StatusNotImplemented: + return errdefs.ErrNotImplemented + case http.StatusServiceUnavailable: + return errdefs.ErrUnavailable + default: + return cause.ErrUnexpectedStatus{Status: statusCode} + } +} diff --git a/grpc_test.go b/errhttp/http_test.go similarity index 51% rename from grpc_test.go rename to errhttp/http_test.go index 8c69a40..a6ea320 100644 --- a/grpc_test.go +++ b/errhttp/http_test.go @@ -14,19 +14,23 @@ limitations under the License. */ -package errdefs +package errhttp import ( - "context" "errors" - "fmt" + "net/http" "testing" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" + "github.com/containerd/errdefs" ) -func TestGRPCRoundTrip(t *testing.T) { +func TestHTTPNilInput(t *testing.T) { + if rc := ToHTTP(nil); rc != http.StatusInternalServerError { + t.Fatalf("Expected %d error, got %d", http.StatusInternalServerError, rc) + } +} + +func TestHTTPRoundTrip(t *testing.T) { errShouldLeaveAlone := errors.New("unknown to package") for _, testcase := range []struct { @@ -35,55 +39,59 @@ func TestGRPCRoundTrip(t *testing.T) { str string }{ { - input: ErrAlreadyExists, - cause: ErrAlreadyExists, + input: errdefs.ErrInvalidArgument, + cause: errdefs.ErrInvalidArgument, }, { - input: ErrNotFound, - cause: ErrNotFound, + input: errdefs.ErrNotFound, + cause: errdefs.ErrNotFound, }, - //nolint:dupword { - input: fmt.Errorf("test test test: %w", ErrFailedPrecondition), - cause: ErrFailedPrecondition, - str: "test test test: failed precondition", + input: errdefs.ErrConflict, + cause: errdefs.ErrConflict, }, { - input: status.Errorf(codes.Unavailable, "should be not available"), - cause: ErrUnavailable, - str: "should be not available: unavailable", + input: errdefs.ErrNotModified, + cause: errdefs.ErrNotModified, }, { - input: errShouldLeaveAlone, - cause: ErrUnknown, - str: errShouldLeaveAlone.Error() + ": " + ErrUnknown.Error(), + input: errdefs.ErrFailedPrecondition, + cause: errdefs.ErrFailedPrecondition, + }, + { + input: errdefs.ErrUnauthenticated, + cause: errdefs.ErrUnauthenticated, }, { - input: context.Canceled, - cause: context.Canceled, - str: "context canceled", + input: errdefs.ErrPermissionDenied, + cause: errdefs.ErrPermissionDenied, }, { - input: fmt.Errorf("this is a test cancel: %w", context.Canceled), - cause: context.Canceled, - str: "this is a test cancel: context canceled", + input: errdefs.ErrResourceExhausted, + cause: errdefs.ErrResourceExhausted, }, { - input: context.DeadlineExceeded, - cause: context.DeadlineExceeded, - str: "context deadline exceeded", + input: errdefs.ErrInternal, + cause: errdefs.ErrInternal, }, { - input: fmt.Errorf("this is a test deadline exceeded: %w", context.DeadlineExceeded), - cause: context.DeadlineExceeded, - str: "this is a test deadline exceeded: context deadline exceeded", + input: errdefs.ErrNotImplemented, + cause: errdefs.ErrNotImplemented, + }, + { + input: errdefs.ErrUnavailable, + cause: errdefs.ErrUnavailable, + }, + { + input: errShouldLeaveAlone, + cause: errdefs.ErrInternal, }, } { t.Run(testcase.input.Error(), func(t *testing.T) { t.Logf("input: %v", testcase.input) - gerr := ToGRPC(testcase.input) - t.Logf("grpc: %v", gerr) - ferr := FromGRPC(gerr) + httpErr := ToHTTP(testcase.input) + t.Logf("http: %v", httpErr) + ferr := ToNative(httpErr) t.Logf("recovered: %v", ferr) if !errors.Is(ferr, testcase.cause) { @@ -99,5 +107,4 @@ func TestGRPCRoundTrip(t *testing.T) { } }) } - } diff --git a/errors.go b/errors.go index 8762255..f654d19 100644 --- a/errors.go +++ b/errors.go @@ -21,9 +21,6 @@ // // To detect an error class, use the IsXXX functions to tell whether an error // is of a certain type. -// -// The functions ToGRPC and FromGRPC can be used to map server-side and -// client-side errors to the correct types. package errdefs import ( @@ -36,57 +33,411 @@ import ( // Packages should return errors of these types when they want to instruct a // client to take a particular action. // -// For the most part, we just try to provide local grpc errors. Most conditions -// map very well to those defined by grpc. +// These errors map closely to grpc errors. var ( - ErrUnknown = errors.New("unknown") // used internally to represent a missed mapping. - ErrInvalidArgument = errors.New("invalid argument") - ErrNotFound = errors.New("not found") - ErrAlreadyExists = errors.New("already exists") - ErrFailedPrecondition = errors.New("failed precondition") - ErrUnavailable = errors.New("unavailable") - ErrNotImplemented = errors.New("not implemented") // represents not supported and unimplemented + ErrUnknown = errUnknown{} + ErrInvalidArgument = errInvalidArgument{} + ErrNotFound = errNotFound{} + ErrAlreadyExists = errAlreadyExists{} + ErrPermissionDenied = errPermissionDenied{} + ErrResourceExhausted = errResourceExhausted{} + ErrFailedPrecondition = errFailedPrecondition{} + ErrConflict = errConflict{} + ErrNotModified = errNotModified{} + ErrAborted = errAborted{} + ErrOutOfRange = errOutOfRange{} + ErrNotImplemented = errNotImplemented{} + ErrInternal = errInternal{} + ErrUnavailable = errUnavailable{} + ErrDataLoss = errDataLoss{} + ErrUnauthenticated = errUnauthorized{} ) +// cancelled maps to Moby's "ErrCancelled" +type cancelled interface { + Cancelled() +} + +// IsCanceled returns true if the error is due to `context.Canceled`. +func IsCanceled(err error) bool { + return errors.Is(err, context.Canceled) || isInterface[cancelled](err) +} + +type errUnknown struct{} + +func (errUnknown) Error() string { return "unknown" } + +func (errUnknown) Unknown() {} + +func (e errUnknown) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// unknown maps to Moby's "ErrUnknown" +type unknown interface { + Unknown() +} + +// IsUnknown returns true if the error is due to an unknown error, +// unhandled condition or unexpected response. +func IsUnknown(err error) bool { + return errors.Is(err, errUnknown{}) || isInterface[unknown](err) +} + +type errInvalidArgument struct{} + +func (errInvalidArgument) Error() string { return "invalid argument" } + +func (errInvalidArgument) InvalidParameter() {} + +func (e errInvalidArgument) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// invalidParameter maps to Moby's "ErrInvalidParameter" +type invalidParameter interface { + InvalidParameter() +} + // IsInvalidArgument returns true if the error is due to an invalid argument func IsInvalidArgument(err error) bool { - return errors.Is(err, ErrInvalidArgument) + return errors.Is(err, ErrInvalidArgument) || isInterface[invalidParameter](err) +} + +// deadlineExceed maps to Moby's "ErrDeadline" +type deadlineExceeded interface { + DeadlineExceeded() +} + +// IsDeadlineExceeded returns true if the error is due to +// `context.DeadlineExceeded`. +func IsDeadlineExceeded(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || isInterface[deadlineExceeded](err) +} + +type errNotFound struct{} + +func (errNotFound) Error() string { return "not found" } + +func (errNotFound) NotFound() {} + +func (e errNotFound) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// notFound maps to Moby's "ErrNotFound" +type notFound interface { + NotFound() } // IsNotFound returns true if the error is due to a missing object func IsNotFound(err error) bool { - return errors.Is(err, ErrNotFound) + return errors.Is(err, ErrNotFound) || isInterface[notFound](err) +} + +type errAlreadyExists struct{} + +func (errAlreadyExists) Error() string { return "already exists" } + +func (errAlreadyExists) AlreadyExists() {} + +func (e errAlreadyExists) WithMessage(msg string) error { + return customMessage{e, msg} +} + +type alreadyExists interface { + AlreadyExists() } // IsAlreadyExists returns true if the error is due to an already existing // metadata item func IsAlreadyExists(err error) bool { - return errors.Is(err, ErrAlreadyExists) + return errors.Is(err, ErrAlreadyExists) || isInterface[alreadyExists](err) +} + +type errPermissionDenied struct{} + +func (errPermissionDenied) Error() string { return "permission denied" } + +func (errPermissionDenied) Forbidden() {} + +func (e errPermissionDenied) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// forbidden maps to Moby's "ErrForbidden" +type forbidden interface { + Forbidden() +} + +// IsPermissionDenied returns true if the error is due to permission denied +// or forbidden (403) response +func IsPermissionDenied(err error) bool { + return errors.Is(err, ErrPermissionDenied) || isInterface[forbidden](err) +} + +type errResourceExhausted struct{} + +func (errResourceExhausted) Error() string { return "resource exhausted" } + +func (errResourceExhausted) ResourceExhausted() {} + +func (e errResourceExhausted) WithMessage(msg string) error { + return customMessage{e, msg} +} + +type resourceExhausted interface { + ResourceExhausted() +} + +// IsResourceExhausted returns true if the error is due to +// a lack of resources or too many attempts. +func IsResourceExhausted(err error) bool { + return errors.Is(err, errResourceExhausted{}) || isInterface[resourceExhausted](err) +} + +type errFailedPrecondition struct{} + +func (e errFailedPrecondition) Error() string { return "failed precondition" } + +func (errFailedPrecondition) FailedPrecondition() {} + +func (e errFailedPrecondition) WithMessage(msg string) error { + return customMessage{e, msg} +} + +type failedPrecondition interface { + FailedPrecondition() } -// IsFailedPrecondition returns true if an operation could not proceed to the -// lack of a particular condition +// IsFailedPrecondition returns true if an operation could not proceed due to +// the lack of a particular condition func IsFailedPrecondition(err error) bool { - return errors.Is(err, ErrFailedPrecondition) + return errors.Is(err, errFailedPrecondition{}) || isInterface[failedPrecondition](err) } -// IsUnavailable returns true if the error is due to a resource being unavailable -func IsUnavailable(err error) bool { - return errors.Is(err, ErrUnavailable) +type errConflict struct{} + +func (errConflict) Error() string { return "conflict" } + +func (errConflict) Conflict() {} + +func (e errConflict) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// conflict maps to Moby's "ErrConflict" +type conflict interface { + Conflict() +} + +// IsConflict returns true if an operation could not proceed due to +// a conflict. +func IsConflict(err error) bool { + return errors.Is(err, errConflict{}) || isInterface[conflict](err) +} + +type errNotModified struct{} + +func (errNotModified) Error() string { return "not modified" } + +func (errNotModified) NotModified() {} + +func (e errNotModified) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// notModified maps to Moby's "ErrNotModified" +type notModified interface { + NotModified() +} + +// IsNotModified returns true if an operation could not proceed due +// to an object not modified from a previous state. +func IsNotModified(err error) bool { + return errors.Is(err, errNotModified{}) || isInterface[notModified](err) +} + +type errAborted struct{} + +func (errAborted) Error() string { return "aborted" } + +func (errAborted) Aborted() {} + +func (e errAborted) WithMessage(msg string) error { + return customMessage{e, msg} +} + +type aborted interface { + Aborted() +} + +// IsAborted returns true if an operation was aborted. +func IsAborted(err error) bool { + return errors.Is(err, errAborted{}) || isInterface[aborted](err) +} + +type errOutOfRange struct{} + +func (errOutOfRange) Error() string { return "out of range" } + +func (errOutOfRange) OutOfRange() {} + +func (e errOutOfRange) WithMessage(msg string) error { + return customMessage{e, msg} +} + +type outOfRange interface { + OutOfRange() +} + +// IsOutOfRange returns true if an operation could not proceed due +// to data being out of the expected range. +func IsOutOfRange(err error) bool { + return errors.Is(err, errOutOfRange{}) || isInterface[outOfRange](err) +} + +type errNotImplemented struct{} + +func (errNotImplemented) Error() string { return "not implemented" } + +func (errNotImplemented) NotImplemented() {} + +func (e errNotImplemented) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// notImplemented maps to Moby's "ErrNotImplemented" +type notImplemented interface { + NotImplemented() } // IsNotImplemented returns true if the error is due to not being implemented func IsNotImplemented(err error) bool { - return errors.Is(err, ErrNotImplemented) + return errors.Is(err, errNotImplemented{}) || isInterface[notImplemented](err) } -// IsCanceled returns true if the error is due to `context.Canceled`. -func IsCanceled(err error) bool { - return errors.Is(err, context.Canceled) +type errInternal struct{} + +func (errInternal) Error() string { return "internal" } + +func (errInternal) System() {} + +func (e errInternal) WithMessage(msg string) error { + return customMessage{e, msg} } -// IsDeadlineExceeded returns true if the error is due to -// `context.DeadlineExceeded`. -func IsDeadlineExceeded(err error) bool { - return errors.Is(err, context.DeadlineExceeded) +// system maps to Moby's "ErrSystem" +type system interface { + System() +} + +// IsInternal returns true if the error returns to an internal or system error +func IsInternal(err error) bool { + return errors.Is(err, errInternal{}) || isInterface[system](err) +} + +type errUnavailable struct{} + +func (errUnavailable) Error() string { return "unavailable" } + +func (errUnavailable) Unavailable() {} + +func (e errUnavailable) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// unavailable maps to Moby's "ErrUnavailable" +type unavailable interface { + Unavailable() +} + +// IsUnavailable returns true if the error is due to a resource being unavailable +func IsUnavailable(err error) bool { + return errors.Is(err, errUnavailable{}) || isInterface[unavailable](err) +} + +type errDataLoss struct{} + +func (errDataLoss) Error() string { return "data loss" } + +func (errDataLoss) DataLoss() {} + +func (e errDataLoss) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// dataLoss maps to Moby's "ErrDataLoss" +type dataLoss interface { + DataLoss() +} + +// IsDataLoss returns true if data during an operation was lost or corrupted +func IsDataLoss(err error) bool { + return errors.Is(err, errDataLoss{}) || isInterface[dataLoss](err) +} + +type errUnauthorized struct{} + +func (errUnauthorized) Error() string { return "unauthorized" } + +func (errUnauthorized) Unauthorized() {} + +func (e errUnauthorized) WithMessage(msg string) error { + return customMessage{e, msg} +} + +// unauthorized maps to Moby's "ErrUnauthorized" +type unauthorized interface { + Unauthorized() +} + +// IsUnauthorized returns true if the error indicates that the user was +// unauthenticated or unauthorized. +func IsUnauthorized(err error) bool { + return errors.Is(err, errUnauthorized{}) || isInterface[unauthorized](err) +} + +func isInterface[T any](err error) bool { + for { + switch x := err.(type) { + case T: + return true + case customMessage: + err = x.err + case interface{ Unwrap() error }: + err = x.Unwrap() + if err == nil { + return false + } + case interface{ Unwrap() []error }: + for _, err := range x.Unwrap() { + if isInterface[T](err) { + return true + } + } + return false + default: + return false + } + } +} + +// customMessage is used to provide a defined error with a custom message. +// The message is not wrapped but can be compared by the `Is(error) bool` interface. +type customMessage struct { + err error + msg string +} + +func (c customMessage) Is(err error) bool { + return c.err == err +} + +func (c customMessage) As(target any) bool { + return errors.As(c.err, target) +} + +func (c customMessage) Error() string { + return c.msg } diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..d4cb0d9 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,205 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package errdefs + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" +) + +func TestInvalidArgument(t *testing.T) { + for _, match := range []error{ + ErrInvalidArgument, + &errInvalidArgument{}, + &customInvalidArgument{}, + &wrappedInvalidArgument{errors.New("invalid parameter")}, + } { + if !IsInvalidArgument(match) { + t.Errorf("error did not match invalid argument: %#v", match) + } + } + for _, nonMatch := range []error{ + ErrUnknown, + context.Canceled, + errors.New("invalid argument"), + } { + if IsInvalidArgument(nonMatch) { + t.Errorf("error unexpectedly matched invalid argument: %#v", nonMatch) + } + } +} + +func TestErrorEquivalence(t *testing.T) { + var e1 error = ErrAborted + var e2 error = ErrUnknown + if e1 == e2 { + t.Fatal("should not equal the same error") + } + if errors.Is(e1, e2) { + t.Fatal("errors.Is should not return true") + } + + var e3 error = errAborted{} + if e1 != e3 { + t.Fatal("new instance should be equivalent") + } + if !errors.Is(e1, e3) { + t.Fatal("errors.Is should be true") + } + if !errors.Is(e3, e1) { + t.Fatal("errors.Is should be true") + } + var aborted errAborted + if !errors.As(e1, &aborted) { + t.Fatal("errors.As should be true") + } + + var e4 = ErrAborted.WithMessage("custom message") + if e1 == e4 { + t.Fatal("should not equal the same error") + } + + if !errors.Is(e4, e1) { + t.Fatal("errors.Is should be true, e1 is in the tree of e4") + } + + if errors.Is(e1, e4) { + t.Fatal("errors.Is should be false, e1 is not a custom message") + } + + if !errors.As(e4, &aborted) { + t.Fatal("errors.As should be true") + } + + var custom customMessage + if !errors.As(e4, &custom) { + t.Fatal("errors.As should be true") + } + if custom.msg != "custom message" { + t.Fatalf("unexpected custom message: %q", custom.msg) + } + if custom.err != e1 { + t.Fatalf("unexpected custom message error: %v", custom.err) + } +} + +func TestWithMessage(t *testing.T) { + testErrors := []error{ErrUnknown, + ErrInvalidArgument, + ErrNotFound, + ErrAlreadyExists, + ErrPermissionDenied, + ErrResourceExhausted, + ErrFailedPrecondition, + ErrConflict, + ErrNotModified, + ErrAborted, + ErrOutOfRange, + ErrNotImplemented, + ErrInternal, + ErrUnavailable, + ErrDataLoss, + ErrUnauthenticated, + } + for _, err := range testErrors { + e1 := err + t.Run(err.Error(), func(t *testing.T) { + wm, ok := e1.(interface{ WithMessage(string) error }) + if !ok { + t.Fatal("WithMessage not supported") + } + e2 := wm.WithMessage("custom message") + + if e1 == e2 { + t.Fatal("should not equal the same error") + } + + if !errors.Is(e2, e1) { + t.Fatal("errors.Is should return true") + } + + if errors.Is(e1, e2) { + t.Fatal("errors.Is should be false, e1 is not a custom message") + } + + var raw = reflect.New(reflect.TypeOf(e1)).Interface() + if !errors.As(e2, raw) { + t.Fatal("errors.As should be true") + } + + var custom customMessage + if !errors.As(e2, &custom) { + t.Fatal("errors.As should be true") + } + if custom.msg != "custom message" { + t.Fatalf("unexpected custom message: %q", custom.msg) + } + if custom.err != e1 { + t.Fatalf("unexpected custom message error: %v", custom.err) + } + + }) + } +} + +func TestInterfaceMatch(t *testing.T) { + testCases := []struct { + err error + check func(error) bool + }{ + {ErrUnknown, isInterface[unknown]}, + {ErrInvalidArgument, isInterface[invalidParameter]}, + {ErrNotFound, isInterface[notFound]}, + {ErrAlreadyExists, isInterface[alreadyExists]}, + {ErrPermissionDenied, isInterface[forbidden]}, + {ErrResourceExhausted, isInterface[resourceExhausted]}, + {ErrFailedPrecondition, isInterface[failedPrecondition]}, + {ErrConflict, isInterface[conflict]}, + {ErrNotModified, isInterface[notModified]}, + {ErrAborted, isInterface[aborted]}, + {ErrOutOfRange, isInterface[outOfRange]}, + {ErrNotImplemented, isInterface[notImplemented]}, + {ErrInternal, isInterface[system]}, + {ErrUnavailable, isInterface[unavailable]}, + {ErrDataLoss, isInterface[dataLoss]}, + {ErrUnauthenticated, isInterface[unauthorized]}, + } + + for _, tc := range testCases { + tc := tc + t.Run(fmt.Sprintf("%T", tc.err), func(t *testing.T) { + if !tc.check(tc.err) { + t.Fatal("Error does not match interface") + } + }) + } +} + +type customInvalidArgument struct{} + +func (*customInvalidArgument) Error() string { + return "my own invalid argument" +} + +func (*customInvalidArgument) InvalidParameter() {} + +type wrappedInvalidArgument struct{ error } + +func (*wrappedInvalidArgument) InvalidParameter() {} diff --git a/go.mod b/go.mod index d6525c7..3fbcfa3 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,15 @@ 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-20231002182017-d307bd883b97 + google.golang.org/grpc v1.58.3 + google.golang.org/protobuf v1.31.0 +) require ( + github.com/gogo/protobuf v1.3.2 // indirect 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 ) diff --git a/go.sum b/go.sum index 4e7237e..66bcada 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,51 @@ +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= +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.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +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.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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= 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.27.1/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= diff --git a/grpc.go b/grpc.go deleted file mode 100644 index 7a9b33e..0000000 --- a/grpc.go +++ /dev/null @@ -1,147 +0,0 @@ -/* - Copyright The containerd Authors. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package errdefs - -import ( - "context" - "fmt" - "strings" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -// ToGRPC will attempt to map the backend containerd error into a grpc error, -// using the original error message as a description. -// -// Further information may be extracted from certain errors depending on their -// type. -// -// If the error is unmapped, the original error will be returned to be handled -// by the regular grpc error handling stack. -func ToGRPC(err error) error { - if err == nil { - return nil - } - - if isGRPCError(err) { - // error has already been mapped to grpc - return err - } - - switch { - case IsInvalidArgument(err): - return status.Errorf(codes.InvalidArgument, err.Error()) - case IsNotFound(err): - return status.Errorf(codes.NotFound, err.Error()) - case IsAlreadyExists(err): - return status.Errorf(codes.AlreadyExists, err.Error()) - case IsFailedPrecondition(err): - return status.Errorf(codes.FailedPrecondition, err.Error()) - case IsUnavailable(err): - return status.Errorf(codes.Unavailable, err.Error()) - case IsNotImplemented(err): - return status.Errorf(codes.Unimplemented, err.Error()) - case IsCanceled(err): - return status.Errorf(codes.Canceled, err.Error()) - case IsDeadlineExceeded(err): - return status.Errorf(codes.DeadlineExceeded, err.Error()) - } - - return err -} - -// ToGRPCf maps the error to grpc error codes, assembling the formatting string -// and combining it with the target error string. -// -// This is equivalent to errdefs.ToGRPC(fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)) -func ToGRPCf(err error, format string, args ...interface{}) error { - return ToGRPC(fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)) -} - -// FromGRPC returns the underlying error from a grpc service based on the grpc error code -func FromGRPC(err error) error { - if err == nil { - return nil - } - - var cls error // divide these into error classes, becomes the cause - - switch code(err) { - case codes.InvalidArgument: - cls = ErrInvalidArgument - case codes.AlreadyExists: - cls = ErrAlreadyExists - case codes.NotFound: - cls = ErrNotFound - case codes.Unavailable: - cls = ErrUnavailable - case codes.FailedPrecondition: - cls = ErrFailedPrecondition - case codes.Unimplemented: - cls = ErrNotImplemented - case codes.Canceled: - cls = context.Canceled - case codes.DeadlineExceeded: - cls = context.DeadlineExceeded - default: - cls = ErrUnknown - } - - msg := rebaseMessage(cls, err) - if msg != "" { - err = fmt.Errorf("%s: %w", msg, cls) - } else { - err = cls - } - - return err -} - -// rebaseMessage removes the repeats for an error at the end of an error -// string. This will happen when taking an error over grpc then remapping it. -// -// Effectively, we just remove the string of cls from the end of err if it -// appears there. -func rebaseMessage(cls error, err error) string { - desc := errDesc(err) - clss := cls.Error() - if desc == clss { - return "" - } - - 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/internal/cause/cause.go b/internal/cause/cause.go new file mode 100644 index 0000000..d88756b --- /dev/null +++ b/internal/cause/cause.go @@ -0,0 +1,33 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package cause is used to define root causes for errors +// common to errors packages like grpc and http. +package cause + +import "fmt" + +type ErrUnexpectedStatus struct { + Status int +} + +const UnexpectedStatusPrefix = "unexpected status " + +func (e ErrUnexpectedStatus) Error() string { + return fmt.Sprintf("%s%d", UnexpectedStatusPrefix, e.Status) +} + +func (ErrUnexpectedStatus) Unknown() {} diff --git a/internal/types/collapsible.go b/internal/types/collapsible.go new file mode 100644 index 0000000..a37e772 --- /dev/null +++ b/internal/types/collapsible.go @@ -0,0 +1,57 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package types + +import "fmt" + +// CollapsibleError indicates the error should be collapsed +type CollapsibleError interface { + CollapseError() +} + +// CollapsedError returns a new error with the collapsed +// error returned on unwrapped or when formatted with "%+v" +func CollapsedError(err error, collapsed ...error) error { + return collapsedError{err, collapsed} +} + +type collapsedError struct { + error + collapsed []error +} + +func (c collapsedError) Unwrap() []error { + return append([]error{c.error}, c.collapsed...) +} + +func (c collapsedError) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') { + fmt.Fprintf(s, "%+v", c.error) + for _, err := range c.collapsed { + fmt.Fprintf(s, "\n%+v", err) + } + return + } + fallthrough + case 's': + fmt.Fprint(s, c.Error()) + case 'q': + fmt.Fprintf(s, "%q", c.Error()) + } +} diff --git a/resolve.go b/resolve.go new file mode 100644 index 0000000..c02d4a7 --- /dev/null +++ b/resolve.go @@ -0,0 +1,147 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package errdefs + +import "context" + +// Resolve returns the first error found in the error chain which matches an +// error defined in this package or context error. A raw, unwrapped error is +// returned or ErrUnknown if no matching error is found. +// +// This is useful for determining a response code based on the outermost wrapped +// error rather than the original cause. For example, a not found error deep +// in the code may be wrapped as an invalid argument. When determining status +// code from Is* functions, the depth or ordering of the error is not +// considered. +// +// The search order is depth first, a wrapped error returned from any part of +// the chain from `Unwrap() error` will be returned before any joined errors +// as returned by `Unwrap() []error`. +func Resolve(err error) error { + if err == nil { + return nil + } + err = firstError(err) + if err == nil { + err = ErrUnknown + } + return err +} + +func firstError(err error) error { + for { + switch err { + case ErrUnknown, + ErrInvalidArgument, + ErrNotFound, + ErrAlreadyExists, + ErrPermissionDenied, + ErrResourceExhausted, + ErrFailedPrecondition, + ErrConflict, + ErrNotModified, + ErrAborted, + ErrOutOfRange, + ErrNotImplemented, + ErrInternal, + ErrUnavailable, + ErrDataLoss, + ErrUnauthenticated, + context.DeadlineExceeded, + context.Canceled: + return err + } + switch e := err.(type) { + case customMessage: + err = e.err + case unknown: + return ErrUnknown + case invalidParameter: + return ErrInvalidArgument + case notFound: + return ErrNotFound + case alreadyExists: + return ErrAlreadyExists + case forbidden: + return ErrPermissionDenied + case resourceExhausted: + return ErrResourceExhausted + case failedPrecondition: + return ErrFailedPrecondition + case conflict: + return ErrConflict + case notModified: + return ErrNotModified + case aborted: + return ErrAborted + case errOutOfRange: + return ErrOutOfRange + case notImplemented: + return ErrNotImplemented + case system: + return ErrInternal + case unavailable: + return ErrUnavailable + case dataLoss: + return ErrDataLoss + case unauthorized: + return ErrUnauthenticated + case deadlineExceeded: + return context.DeadlineExceeded + case cancelled: + return context.Canceled + case interface{ Unwrap() error }: + err = e.Unwrap() + if err == nil { + return nil + } + case interface{ Unwrap() []error }: + for _, ue := range e.Unwrap() { + if fe := firstError(ue); fe != nil { + return fe + } + } + return nil + case interface{ Is(error) bool }: + for _, target := range []error{ErrUnknown, + ErrInvalidArgument, + ErrNotFound, + ErrAlreadyExists, + ErrPermissionDenied, + ErrResourceExhausted, + ErrFailedPrecondition, + ErrConflict, + ErrNotModified, + ErrAborted, + ErrOutOfRange, + ErrNotImplemented, + ErrInternal, + ErrUnavailable, + ErrDataLoss, + ErrUnauthenticated, + context.DeadlineExceeded, + context.Canceled} { + if e.Is(target) { + return target + } + } + return nil + default: + return nil + } + } +} diff --git a/resolve_test.go b/resolve_test.go new file mode 100644 index 0000000..8cebb8a --- /dev/null +++ b/resolve_test.go @@ -0,0 +1,94 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package errdefs + +import ( + "context" + "errors" + "fmt" + "testing" +) + +func TestResolve(t *testing.T) { + for i, tc := range []struct { + err error + resolved error + }{ + {nil, nil}, + {wrap(ErrUnknown), ErrUnknown}, + {wrap(ErrNotFound), ErrNotFound}, + {wrap(ErrInvalidArgument), ErrInvalidArgument}, + {wrap(ErrNotFound), ErrNotFound}, + {wrap(ErrAlreadyExists), ErrAlreadyExists}, + {wrap(ErrPermissionDenied), ErrPermissionDenied}, + {wrap(ErrResourceExhausted), ErrResourceExhausted}, + {wrap(ErrFailedPrecondition), ErrFailedPrecondition}, + {wrap(ErrConflict), ErrConflict}, + {wrap(ErrNotModified), ErrNotModified}, + {wrap(ErrAborted), ErrAborted}, + {wrap(ErrOutOfRange), ErrOutOfRange}, + {wrap(ErrNotImplemented), ErrNotImplemented}, + {wrap(ErrInternal), ErrInternal}, + {wrap(ErrUnavailable), ErrUnavailable}, + {wrap(ErrDataLoss), ErrDataLoss}, + {wrap(ErrUnauthenticated), ErrUnauthenticated}, + {wrap(context.DeadlineExceeded), context.DeadlineExceeded}, + {wrap(context.Canceled), context.Canceled}, + {errors.Join(errors.New("untyped"), wrap(ErrInvalidArgument)), ErrInvalidArgument}, + {errors.Join(ErrConflict, ErrNotFound), ErrConflict}, + {errors.New("untyped"), ErrUnknown}, + {errors.Join(wrap(ErrUnauthenticated), ErrNotModified), ErrUnauthenticated}, + {ErrDataLoss, ErrDataLoss}, + {errors.Join(ErrOutOfRange), ErrOutOfRange}, + {errors.Join(ErrNotImplemented, ErrInternal), ErrNotImplemented}, + {context.Canceled, context.Canceled}, + {testUnavailable{}, ErrUnavailable}, + {wrap(testUnavailable{}), ErrUnavailable}, + {errors.Join(testUnavailable{}, ErrPermissionDenied), ErrUnavailable}, + {errors.Join(errors.New("untyped join")), ErrUnknown}, + {errors.Join(errors.New("untyped1"), errors.New("untyped2")), ErrUnknown}, + {ErrNotFound.WithMessage("something else"), ErrNotFound}, + {wrap(ErrNotFound.WithMessage("something else")), ErrNotFound}, + {errors.Join(ErrNotFound.WithMessage("something else"), ErrPermissionDenied), ErrNotFound}, + } { + name := fmt.Sprintf("%d-%s", i, errorString(tc.resolved)) + tc := tc + t.Run(name, func(t *testing.T) { + resolved := Resolve(tc.err) + if resolved != tc.resolved { + t.Errorf("Expected %s, got %s", tc.resolved, resolved) + } + }) + } +} + +func wrap(err error) error { + err = fmt.Errorf("wrapped error: %w", err) + return fmt.Errorf("%w and also %w", err, ErrUnknown) +} + +func errorString(err error) string { + if err == nil { + return "nil" + } + return err.Error() +} + +type testUnavailable struct{} + +func (testUnavailable) Error() string { return "" } +func (testUnavailable) Unavailable() {} diff --git a/stack/stack.go b/stack/stack.go new file mode 100644 index 0000000..befbf3c --- /dev/null +++ b/stack/stack.go @@ -0,0 +1,296 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package stack + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "runtime" + "strings" + "sync/atomic" + "unsafe" + + "github.com/containerd/typeurl/v2" + + "github.com/containerd/errdefs/internal/types" +) + +func init() { + typeurl.Register((*stack)(nil), "github.com/containerd/errdefs", "stack+json") +} + +var ( + // Version is version of running process + Version string = "dev" + + // Revision is the specific revision of the running process + Revision string = "dirty" +) + +type stack struct { + decoded *Trace + + callers []uintptr + helpers []uintptr +} + +// Trace is a stack trace along with process information about the source +type Trace struct { + Version string `json:"version,omitempty"` + Revision string `json:"revision,omitempty"` + Cmdline []string `json:"cmdline,omitempty"` + Frames []Frame `json:"frames,omitempty"` + Pid int32 `json:"pid,omitempty"` +} + +// Frame is a single frame of the trace representing a line of code +type Frame struct { + Name string `json:"Name,omitempty"` + File string `json:"File,omitempty"` + Line int32 `json:"Line,omitempty"` +} + +func (f Frame) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + switch { + case s.Flag('+'): + fmt.Fprintf(s, "%s\n\t%s:%d\n", f.Name, f.File, f.Line) + default: + fmt.Fprint(s, f.Name) + } + case 's': + fmt.Fprint(s, path.Base(f.Name)) + case 'q': + fmt.Fprintf(s, "%q", path.Base(f.Name)) + } +} + +// callers returns the current stack, skipping over the number of frames mentioned +// Frames with skip=0: +// +// frame[0] runtime.Callers +// frame[1] github.com/containerd/errdefs/stack.callers +// frame[2] (Use skip=2 to have this be first frame) +func callers(skip int) *stack { + const depth = 32 + var pcs [depth]uintptr + n := runtime.Callers(skip, pcs[:]) + return &stack{ + callers: pcs[0:n], + } +} + +func (s *stack) getDecoded() *Trace { + if s.decoded == nil { + var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded)) + + var helpers map[string]struct{} + if len(s.helpers) > 0 { + helpers = make(map[string]struct{}) + frames := runtime.CallersFrames(s.helpers) + for { + frame, more := frames.Next() + helpers[frame.Function] = struct{}{} + if !more { + break + } + } + } + + f := make([]Frame, 0, len(s.callers)) + if len(s.callers) > 0 { + frames := runtime.CallersFrames(s.callers) + for { + frame, more := frames.Next() + if _, ok := helpers[frame.Function]; !ok { + f = append(f, Frame{ + Name: frame.Function, + File: frame.File, + Line: int32(frame.Line), + }) + } + if !more { + break + } + } + } + + t := Trace{ + Version: Version, + Revision: Revision, + Cmdline: os.Args, + Frames: f, + Pid: int32(os.Getpid()), + } + + atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t)) + } + + return s.decoded +} + +func (s *stack) Error() string { + return fmt.Sprintf("%+v", s.getDecoded()) +} + +func (s *stack) MarshalJSON() ([]byte, error) { + return json.Marshal(s.getDecoded()) +} + +func (s *stack) UnmarshalJSON(b []byte) error { + var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded)) + var t Trace + + if err := json.Unmarshal(b, &t); err != nil { + return err + } + + atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t)) + + return nil +} + +func (s *stack) Format(st fmt.State, verb rune) { + switch verb { + case 'v': + if st.Flag('+') { + t := s.getDecoded() + fmt.Fprintf(st, "%d %s %s\n", t.Pid, t.Version, strings.Join(t.Cmdline, " ")) + for _, f := range t.Frames { + f.Format(st, verb) + } + fmt.Fprintln(st) + return + } + } +} + +func (s *stack) StackTrace() Trace { + return *s.getDecoded() +} + +func (s *stack) CollapseError() {} + +// ErrStack returns a new error for the callers stack, +// this can be wrapped or joined into an existing error. +// NOTE: When joined with errors.Join, the stack +// will show up in the error string output. +// Use with `stack.Join` to force addition of the +// error stack. +func ErrStack() error { + return callers(3) +} + +// Join adds a stack if there is no stack included to the errors +// and returns a joined error with the stack hidden from the error +// output. The stack error shows up when Unwrapped or formatted +// with `%+v`. +func Join(errs ...error) error { + return joinErrors(nil, errs) +} + +// WithStack will check if the error already has a stack otherwise +// return a new error with the error joined with a stack error +// Any helpers will be skipped. +func WithStack(ctx context.Context, errs ...error) error { + return joinErrors(ctx.Value(helperKey{}), errs) +} + +func joinErrors(helperVal any, errs []error) error { + var filtered []error + var collapsible []error + var hasStack bool + for _, err := range errs { + if err != nil { + if !hasStack && hasLocalStackTrace(err) { + hasStack = true + } + if _, ok := err.(types.CollapsibleError); ok { + collapsible = append(collapsible, err) + } else { + filtered = append(filtered, err) + } + + } + } + if len(filtered) == 0 { + return nil + } + if !hasStack { + s := callers(4) + if helpers, ok := helperVal.([]uintptr); ok { + s.helpers = helpers + } + collapsible = append(collapsible, s) + } + var err error + if len(filtered) > 1 { + err = errors.Join(filtered...) + } else { + err = filtered[0] + } + if len(collapsible) == 0 { + return err + } + + return types.CollapsedError(err, collapsible...) +} + +func hasLocalStackTrace(err error) bool { + switch e := err.(type) { + case *stack: + return true + case interface{ Unwrap() error }: + if hasLocalStackTrace(e.Unwrap()) { + return true + } + case interface{ Unwrap() []error }: + for _, ue := range e.Unwrap() { + if hasLocalStackTrace(ue) { + return true + } + } + } + + // TODO: Consider if pkg/errors compatibility is needed + // NOTE: This was implemented before the standard error package + // so it may unwrap and have this interface. + //if _, ok := err.(interface{ StackTrace() pkgerrors.StackTrace }); ok { + // return true + //} + + return false +} + +type helperKey struct{} + +// WithHelper marks the context as from a helper function +// This will add an additional skip to the error stack trace +func WithHelper(ctx context.Context) context.Context { + helpers, _ := ctx.Value(helperKey{}).([]uintptr) + var pcs [1]uintptr + n := runtime.Callers(2, pcs[:]) + if n == 1 { + ctx = context.WithValue(ctx, helperKey{}, append(helpers, pcs[0])) + } + return ctx +} diff --git a/stack/stack_test.go b/stack/stack_test.go new file mode 100644 index 0000000..00cb81d --- /dev/null +++ b/stack/stack_test.go @@ -0,0 +1,98 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package stack + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" +) + +func TestStack(t *testing.T) { + s := callers(2) + if len(s.callers) == 0 { + t.Fatalf("expected callers, got:\n%v", s) + } + tr := s.getDecoded() + if len(tr.Frames) != len(s.callers) { + t.Fatalf("expected 1 frame, got %d", len(tr.Frames)) + } + if name := tr.Frames[0].Name; !strings.HasSuffix(name, "."+t.Name()) { + t.Fatalf("unexpected frame: %s\n%v", name, s) + } +} + +func TestCollapsed(t *testing.T) { + checkError := func(err error, expected string) { + t.Helper() + if err.Error() != expected { + t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected) + } + + if printed := fmt.Sprintf("%v", err); printed != expected { + t.Fatalf("unexpected error string %q, expected %q", printed, expected) + } + + if printed := fmt.Sprintf("%+v", err); !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) { + t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name()) + } + } + expected := "some error" + checkError(Join(errors.New(expected)), expected) + checkError(Join(errors.New(expected), ErrStack()), expected) + checkError(WithStack(context.Background(), errors.New(expected)), expected) +} + +func TestHelpers(t *testing.T) { + checkError := func(err error, expected string, withHelper bool) { + t.Helper() + if err.Error() != expected { + t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected) + } + + if printed := fmt.Sprintf("%v", err); printed != expected { + t.Fatalf("unexpected error string %q, expected %q", printed, expected) + } + + printed := fmt.Sprintf("%+v", err) + if !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) { + t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name()) + } + if withHelper { + if !strings.Contains(printed, "testHelper") { + t.Fatalf("unexpected error string, expected stack containing testHelper:\n%s", printed) + } + } else if strings.Contains(printed, "testHelper") { + t.Fatalf("unexpected error string, expected stack with no containing testHelper:\n%s", printed) + } + } + expected := "some error" + checkError(Join(errors.New(expected)), expected, false) + checkError(testHelper(expected, false), expected, true) + checkError(testHelper(expected, true), expected, false) +} + +func testHelper(msg string, withHelper bool) error { + if withHelper { + return WithStack(WithHelper(context.Background()), errors.New(msg)) + } else { + return WithStack(context.Background(), errors.New(msg)) + } + +}