diff --git a/errors.go b/errors.go index 8762255..0bcc2a6 100644 --- a/errors.go +++ b/errors.go @@ -29,6 +29,7 @@ package errdefs import ( "context" "errors" + "fmt" ) // Definitions of common error types used throughout containerd. All containerd @@ -36,57 +37,306 @@ 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 { + ErrCancelled() +} + +// 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() {} + +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() {} + +// 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() {} + +// 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() {} + +// 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" } + // IsAlreadyExists returns true if the error is due to an already existing // metadata item func IsAlreadyExists(err error) bool { return errors.Is(err, ErrAlreadyExists) } -// IsFailedPrecondition returns true if an operation could not proceed to the -// lack of a particular condition +type errPermissionDenied struct{} + +func (errPermissionDenied) Error() string { return "permission denied" } + +// 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" } + +// 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{}) +} + +type errFailedPrecondition struct{} + +func (e errFailedPrecondition) Error() string { return "failed precondition" } + +// 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{}) } -// 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() {} + +// 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() {} + +// 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" } + +// IsAborted returns true if an operation was aborted. +func IsAborted(err error) bool { + return errors.Is(err, errAborted{}) +} + +type errOutOfRange struct{} + +func (errOutOfRange) Error() string { return "out of range" } + +// 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{}) +} + +type errNotImplemented struct{} + +func (errNotImplemented) Error() string { return "not implemented" } + +func (errNotImplemented) NotImplemented() {} + +// 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() {} + +// system maps to Moby's "ErrSystem" +type system interface { + System() } -// IsDeadlineExceeded returns true if the error is due to -// `context.DeadlineExceeded`. -func IsDeadlineExceeded(err error) bool { - return errors.Is(err, context.DeadlineExceeded) +// 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() {} + +// 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() {} + +// 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() {} + +// 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 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 + } + } } diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..9ec9adb --- /dev/null +++ b/errors_test.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 errdefs + +import ( + "context" + "errors" + "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) + } + } +} + +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/grpc.go b/grpc.go index 7a9b33e..ef885d8 100644 --- a/grpc.go +++ b/grpc.go @@ -19,6 +19,7 @@ package errdefs import ( "context" "fmt" + "strconv" "strings" "google.golang.org/grpc/codes" @@ -45,21 +46,37 @@ func ToGRPC(err error) error { switch { case IsInvalidArgument(err): - return status.Errorf(codes.InvalidArgument, err.Error()) + return status.Error(codes.InvalidArgument, err.Error()) case IsNotFound(err): - return status.Errorf(codes.NotFound, err.Error()) + return status.Error(codes.NotFound, err.Error()) case IsAlreadyExists(err): - return status.Errorf(codes.AlreadyExists, err.Error()) - case IsFailedPrecondition(err): - return status.Errorf(codes.FailedPrecondition, err.Error()) + return status.Error(codes.AlreadyExists, err.Error()) + case IsFailedPrecondition(err) || IsConflict(err) || IsNotModified(err): + return status.Error(codes.FailedPrecondition, err.Error()) case IsUnavailable(err): - return status.Errorf(codes.Unavailable, err.Error()) + return status.Error(codes.Unavailable, err.Error()) case IsNotImplemented(err): - return status.Errorf(codes.Unimplemented, err.Error()) + return status.Error(codes.Unimplemented, err.Error()) case IsCanceled(err): - return status.Errorf(codes.Canceled, err.Error()) + return status.Error(codes.Canceled, err.Error()) case IsDeadlineExceeded(err): - return status.Errorf(codes.DeadlineExceeded, err.Error()) + return status.Error(codes.DeadlineExceeded, err.Error()) + case IsUnauthorized(err): + return status.Error(codes.Unauthenticated, err.Error()) + case IsPermissionDenied(err): + return status.Error(codes.PermissionDenied, err.Error()) + case IsInternal(err): + return status.Error(codes.Internal, err.Error()) + case IsDataLoss(err): + return status.Error(codes.DataLoss, err.Error()) + case IsAborted(err): + return status.Error(codes.Aborted, err.Error()) + case IsOutOfRange(err): + return status.Error(codes.OutOfRange, err.Error()) + case IsResourceExhausted(err): + return status.Error(codes.ResourceExhausted, err.Error()) + case IsUnknown(err): + return status.Error(codes.Unknown, err.Error()) } return err @@ -79,6 +96,8 @@ func FromGRPC(err error) error { return nil } + desc := errDesc(err) + var cls error // divide these into error classes, becomes the cause switch code(err) { @@ -91,18 +110,45 @@ func FromGRPC(err error) error { case codes.Unavailable: cls = ErrUnavailable case codes.FailedPrecondition: - cls = ErrFailedPrecondition + if desc == ErrConflict.Error() || strings.HasSuffix(desc, ": "+ErrConflict.Error()) { + cls = ErrConflict + } else if desc == ErrNotModified.Error() || strings.HasSuffix(desc, ": "+ErrNotModified.Error()) { + cls = ErrNotModified + } else { + cls = ErrFailedPrecondition + } case codes.Unimplemented: cls = ErrNotImplemented case codes.Canceled: cls = context.Canceled case codes.DeadlineExceeded: cls = context.DeadlineExceeded + case codes.Aborted: + cls = ErrAborted + case codes.Unauthenticated: + cls = ErrUnauthenticated + case codes.PermissionDenied: + cls = ErrPermissionDenied + case codes.Internal: + cls = ErrInternal + case codes.DataLoss: + cls = ErrDataLoss + case codes.OutOfRange: + cls = ErrOutOfRange + case codes.ResourceExhausted: + cls = ErrResourceExhausted default: - cls = ErrUnknown + if idx := strings.LastIndex(desc, unexpectedStatusPrefix); idx > 0 { + if status, err := strconv.Atoi(desc[idx+len(unexpectedStatusPrefix):]); err == nil && status >= 200 && status < 600 { + cls = errUnexpectedStatus{status} + } + } + if cls == nil { + cls = ErrUnknown + } } - msg := rebaseMessage(cls, err) + msg := rebaseMessage(cls, desc) if msg != "" { err = fmt.Errorf("%s: %w", msg, cls) } else { @@ -117,8 +163,7 @@ func FromGRPC(err error) error { // // 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) +func rebaseMessage(cls error, desc string) string { clss := cls.Error() if desc == clss { return "" diff --git a/grpc_test.go b/grpc_test.go index 8c69a40..c25d410 100644 --- a/grpc_test.go +++ b/grpc_test.go @@ -78,6 +78,21 @@ func TestGRPCRoundTrip(t *testing.T) { cause: context.DeadlineExceeded, str: "this is a test deadline exceeded: context deadline exceeded", }, + { + input: fmt.Errorf("something conflicted: %w", ErrConflict), + cause: ErrConflict, + str: "something conflicted: conflict", + }, + { + input: fmt.Errorf("everything is the same: %w", ErrNotModified), + cause: ErrNotModified, + str: "everything is the same: not modified", + }, + { + input: fmt.Errorf("odd HTTP response: %w", FromHTTP(418)), + cause: errUnexpectedStatus{418}, + str: "odd HTTP response: unexpected status 418", + }, } { t.Run(testcase.input.Error(), func(t *testing.T) { t.Logf("input: %v", testcase.input) diff --git a/http.go b/http.go new file mode 100644 index 0000000..90ca12f --- /dev/null +++ b/http.go @@ -0,0 +1,88 @@ +/* + 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 ( + "errors" + "net/http" +) + +// FromHTTP returns the error best matching the HTTP status code +func FromHTTP(statusCode int) error { + switch statusCode { + case http.StatusNotFound: + return ErrNotFound + case http.StatusBadRequest: + return ErrInvalidArgument + case http.StatusConflict: + return ErrConflict + case http.StatusPreconditionFailed: + return ErrFailedPrecondition + case http.StatusUnauthorized: + return ErrUnauthenticated + case http.StatusForbidden: + return ErrPermissionDenied + case http.StatusNotModified: + return ErrNotModified + case http.StatusTooManyRequests: + return ErrResourceExhausted + case http.StatusInternalServerError: + return ErrInternal + case http.StatusNotImplemented: + return ErrNotImplemented + case http.StatusServiceUnavailable: + return ErrUnavailable + default: + return errUnexpectedStatus{statusCode} + } +} + +// ToHTTP returns the best status code for the given error +func ToHTTP(err error) int { + switch { + case IsNotFound(err): + return http.StatusNotFound + case IsInvalidArgument(err): + return http.StatusBadRequest + case IsConflict(err): + return http.StatusConflict + case IsNotModified(err): + return http.StatusNotModified + case IsFailedPrecondition(err): + return http.StatusPreconditionFailed + case IsUnauthorized(err): + return http.StatusUnauthorized + case IsPermissionDenied(err): + return http.StatusForbidden + case IsResourceExhausted(err): + return http.StatusTooManyRequests + case IsInternal(err): + return http.StatusInternalServerError + case IsNotImplemented(err): + return http.StatusNotImplemented + case IsUnavailable(err): + return http.StatusServiceUnavailable + case IsUnknown(err): + var unexpected errUnexpectedStatus + if errors.As(err, &unexpected) && unexpected.status >= 200 && unexpected.status < 600 { + return unexpected.status + } + return http.StatusInternalServerError + default: + return http.StatusInternalServerError + } +}