From 3670066fabc9d307bb1e9a6b378a1fa6f04d65e0 Mon Sep 17 00:00:00 2001 From: Alex | Interchain Labs Date: Wed, 5 Mar 2025 13:09:51 -0500 Subject: [PATCH] chore: use errors v1 (#23901) --- errors/CHANGELOG.md | 7 +- errors/abci.go | 55 +++++++--- errors/abci_test.go | 124 +++++++++++++++++---- errors/doc.go | 62 ++++++----- errors/errors.go | 200 +++++++++++++++++++++++++++++++-- errors/errors_test.go | 226 +++++++++++++++++++++++++++++++++++++- errors/go.mod | 24 +++- errors/go.sum | 41 +++++++ errors/handle.go | 12 ++ errors/stacktrace.go | 120 ++++++++++++++++++++ errors/stacktrace_test.go | 62 +++++++++++ 11 files changed, 848 insertions(+), 85 deletions(-) create mode 100644 errors/handle.go create mode 100644 errors/stacktrace.go create mode 100644 errors/stacktrace_test.go diff --git a/errors/CHANGELOG.md b/errors/CHANGELOG.md index 4d563a90b8..94759445ae 100644 --- a/errors/CHANGELOG.md +++ b/errors/CHANGELOG.md @@ -31,12 +31,9 @@ Ref: https://keepachangelog.com/en/1.0.0/ ## [Unreleased] -## [v2.0.0](https://github.com/cosmos/cosmos-sdk/releases/tag/errors/%2Fv2.0.0) +### Improvements -### API Breaking Changes - -* [#20402](https://github.com/cosmos/cosmos-sdk/pull/20402) Remove Grpc error codes from the error package. This is done in order to keep the dependency graph of errors minimal -* [#20539](https://github.com/cosmos/cosmos-sdk/pull/20539) Removes `IsOf`, `Recover`, `WithType` and wrapped error. The errors package uses the go std library errors. It provides a `Wrap` and `Wrapf` to help in the migration from v1 to v2. +* [#23901](https://github.com/cosmos/cosmos-sdk/pull/23901) Minor dependency improvements. ## [v1.0.1](https://github.com/cosmos/cosmos-sdk/releases/tag/errors%2Fv1.0.1) diff --git a/errors/abci.go b/errors/abci.go index 4fa5b959db..603f3b36ff 100644 --- a/errors/abci.go +++ b/errors/abci.go @@ -1,7 +1,6 @@ package errors import ( - "errors" "fmt" "reflect" ) @@ -35,8 +34,7 @@ func ABCIInfo(err error, debug bool) (codespace string, code uint32, log string) encode = debugErrEncoder } - code, space := abciInfo(err) - return space, code, encode(err) + return abciCodespace(err), abciCode(err), encode(err) } // The debugErrEncoder encodes the error with a stacktrace. @@ -48,25 +46,54 @@ func defaultErrEncoder(err error) string { return err.Error() } -// abciInfo tests if given error contains an ABCI code and returns the value of +type coder interface { + ABCICode() uint32 +} + +// abciCode tests if given error contains an ABCI code and returns the value of // it if available. This function is testing for the causer interface as well // and unwraps the error. -func abciInfo(err error) (code uint32, codespace string) { +func abciCode(err error) uint32 { if errIsNil(err) { - return SuccessABCICode, "" + return SuccessABCICode } - var customErr *Error + for { + if c, ok := err.(coder); ok { + return c.ABCICode() + } - if errors.As(err, &customErr) { - code = customErr.ABCICode() - codespace = customErr.Codespace() - } else { - code = internalABCICode - codespace = internalABCICodespace + if c, ok := err.(causer); ok { + err = c.Cause() + } else { + return internalABCICode + } + } +} + +type codespacer interface { + Codespace() string +} + +// abciCodespace tests if given error contains a codespace and returns the value of +// it if available. This function is testing for the causer interface as well +// and unwraps the error. +func abciCodespace(err error) string { + if errIsNil(err) { + return "" } - return + for { + if c, ok := err.(codespacer); ok { + return c.Codespace() + } + + if c, ok := err.(causer); ok { + err = c.Cause() + } else { + return internalABCICodespace + } + } } // errIsNil returns true if value represented by the given error is nil. diff --git a/errors/abci_test.go b/errors/abci_test.go index 54afb88726..be300d9313 100644 --- a/errors/abci_test.go +++ b/errors/abci_test.go @@ -3,10 +3,25 @@ package errors import ( "fmt" "io" + "strings" "testing" + + "github.com/stretchr/testify/suite" ) -func TestABCInfo(t *testing.T) { +type abciTestSuite struct { + suite.Suite +} + +func TestABCITestSuite(t *testing.T) { + suite.Run(t, new(abciTestSuite)) +} + +func (s *abciTestSuite) SetupSuite() { + s.T().Parallel() +} + +func (s *abciTestSuite) TestABCInfo() { cases := map[string]struct { err error debug bool @@ -22,7 +37,7 @@ func TestABCInfo(t *testing.T) { wantSpace: testCodespace, }, "wrapped SDK error": { - err: fmt.Errorf("bar: %w", fmt.Errorf("foo: %w", ErrUnauthorized)), + err: Wrap(Wrap(ErrUnauthorized, "foo"), "bar"), debug: false, wantLog: "bar: foo: unauthorized", wantCode: ErrUnauthorized.code, @@ -49,29 +64,93 @@ func TestABCInfo(t *testing.T) { wantCode: 1, wantSpace: UndefinedCodespace, }, + // This is hard to test because of attached stacktrace. This + // case is tested in an another test. + // "wrapped stdlib is a full message in debug mode": { + // err: Wrap(io.EOF, "cannot read file"), + // debug: true, + // wantLog: "cannot read file: EOF", + // wantCode: 1, + // }, + "custom error": { + err: customErr{}, + debug: false, + wantLog: "custom", + wantCode: 999, + wantSpace: "extern", + }, + "custom error in debug mode": { + err: customErr{}, + debug: true, + wantLog: "custom", + wantCode: 999, + wantSpace: "extern", + }, } for testName, tc := range cases { - t.Run(testName, func(t *testing.T) { + s.T().Run(testName, func(t *testing.T) { space, code, log := ABCIInfo(tc.err, tc.debug) - if space != tc.wantSpace { - t.Errorf("%s: expected space %s, got %s", testName, tc.wantSpace, space) - } - if code != tc.wantCode { - t.Errorf("%s: expected code %d, got %d", testName, tc.wantCode, code) - } - if log != tc.wantLog { - t.Errorf("%s: expected log %s, got %s", testName, tc.wantLog, log) + s.Require().Equal(tc.wantSpace, space, testName) + s.Require().Equal(tc.wantCode, code, testName) + s.Require().Equal(tc.wantLog, log, testName) + }) + } +} + +func (s *abciTestSuite) TestABCIInfoStacktrace() { + cases := map[string]struct { + err error + debug bool + wantStacktrace bool + wantErrMsg string + }{ + "wrapped SDK error in debug mode provides stacktrace": { + err: Wrap(ErrUnauthorized, "wrapped"), + debug: true, + wantStacktrace: true, + wantErrMsg: "wrapped: unauthorized", + }, + "wrapped SDK error in non-debug mode does not have stacktrace": { + err: Wrap(ErrUnauthorized, "wrapped"), + debug: false, + wantStacktrace: false, + wantErrMsg: "wrapped: unauthorized", + }, + "wrapped stdlib error in debug mode provides stacktrace": { + err: Wrap(fmt.Errorf("stdlib"), "wrapped"), + debug: true, + wantStacktrace: true, + wantErrMsg: "wrapped: stdlib", + }, + } + + const thisTestSrc = "cosmossdk.io/errors.(*abciTestSuite).TestABCIInfoStacktrace" + + for testName, tc := range cases { + s.T().Run(testName, func(t *testing.T) { + _, _, log := ABCIInfo(tc.err, tc.debug) + if !tc.wantStacktrace { + s.Require().Equal(tc.wantErrMsg, log, testName) + } else { + s.Require().True(strings.Contains(log, thisTestSrc), testName) + s.Require().True(strings.Contains(log, tc.wantErrMsg), testName) } }) } } -func TestABCIInfoSerializeErr(t *testing.T) { +func (s *abciTestSuite) TestABCIInfoHidesStacktrace() { + err := Wrap(ErrUnauthorized, "wrapped") + _, _, log := ABCIInfo(err, false) + s.Require().Equal("wrapped: unauthorized", log) +} + +func (s *abciTestSuite) TestABCIInfoSerializeErr() { var ( - // Create errors for equal comparison. - myErrDecode = fmt.Errorf("test: %w", ErrTxDecode) - myErrAddr = fmt.Errorf("tester: %w", ErrInvalidAddress) + // Create errors with stacktrace for equal comparison. + myErrDecode = Wrap(ErrTxDecode, "test") + myErrAddr = Wrap(ErrInvalidAddress, "tester") myPanic = ErrPanic ) @@ -102,10 +181,17 @@ func TestABCIInfoSerializeErr(t *testing.T) { }, } for msg, spec := range specs { - _, _, log := ABCIInfo(spec.src, spec.debug) - if log != spec.exp { - t.Errorf("%s: expected log %s, got %s", msg, spec.exp, log) - } + s.Require().Equal(spec.exp, log, msg) } } + +// customErr is a custom implementation of an error that provides an ABCICode +// method. +type customErr struct{} + +func (customErr) Codespace() string { return "extern" } + +func (customErr) ABCICode() uint32 { return 999 } + +func (customErr) Error() string { return "custom" } diff --git a/errors/doc.go b/errors/doc.go index ad904e5bf7..ed6b9a69bf 100644 --- a/errors/doc.go +++ b/errors/doc.go @@ -1,31 +1,33 @@ -// Package errors implements custom error interfaces for cosmos-sdk. -// -// Error declarations should be generic and cover broad range of cases. Each -// returned error instance can wrap a generic error declaration to provide more -// details. -// -// This package provides a broad range of errors declared that fits all common -// cases. If an error is very specific for an extension it can be registered outside -// of the errors package. If it will be needed my many extensions, please consider -// registering it in the errors package. To create a new error instance use Register -// function. You must provide a unique, non-zero error code and a short description, for example: -// -// var ErrZeroDivision = errors.Register(9241, "zero division") -// -// When returning an error, you can attach to it an additional context -// information by using Wrap function, for example: -// -// func safeDiv(val, div int) (int, err) { -// if div == 0 { -// return 0, errors.Wrapf(ErrZeroDivision, "cannot divide %d", val) -// } -// return val / div, nil -// } -// -// The first time an error instance is wrapped a stacktrace is attached as well. -// Stacktrace information can be printed using %+v and %v formats. -// -// %s is just the error message -// %+v is the full stack trace -// %v appends a compressed [filename:line] where the error was created +/* +Package errors implements custom error interfaces for cosmos-sdk. + +Error declarations should be generic and cover broad range of cases. Each +returned error instance can wrap a generic error declaration to provide more +details. + +This package provides a broad range of errors declared that fits all common +cases. If an error is very specific for an extension it can be registered outside +of the errors package. If it will be needed my many extensions, please consider +registering it in the errors package. To create a new error instance use Register +function. You must provide a unique, non zero error code and a short description, for example: + + var ErrZeroDivision = errors.Register(9241, "zero division") + +When returning an error, you can attach to it an additional context +information by using Wrap function, for example: + + func safeDiv(val, div int) (int, err) { + if div == 0 { + return 0, errors.Wrapf(ErrZeroDivision, "cannot divide %d", val) + } + return val / div, nil + } + +The first time an error instance is wrapped a stacktrace is attached as well. +Stacktrace information can be printed using %+v and %v formats. + + %s is just the error message + %+v is the full stack trace + %v appends a compressed [filename:line] where the error was created +*/ package errors diff --git a/errors/errors.go b/errors/errors.go index 88de804660..16f10f36a6 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -2,17 +2,24 @@ package errors import ( "fmt" + "reflect" + + "github.com/pkg/errors" + grpccodes "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" ) // UndefinedCodespace when we explicitly declare no codespace const UndefinedCodespace = "undefined" var ( + // errInternal should never be exposed, but we reserve this code for non-specified errors + errInternal = Register(UndefinedCodespace, 1, "internal") // ErrStopIterating is used to break out of an iteration ErrStopIterating = Register(UndefinedCodespace, 2, "stop iterating") - // ErrPanic should only be set when we recover from a panic + // ErrPanic should only be set when we recovering from a panic ErrPanic = Register(UndefinedCodespace, 111222, "panic") ) @@ -25,11 +32,18 @@ var ( // // Use this function only during a program startup phase. func Register(codespace string, code uint32, description string) *Error { + return RegisterWithGRPCCode(codespace, code, grpccodes.Unknown, description) +} + +// RegisterWithGRPCCode is a version of Register that associates a gRPC error +// code with a registered error. +func RegisterWithGRPCCode(codespace string, code uint32, grpcCode grpccodes.Code, description string) *Error { + // TODO - uniqueness is (codespace, code) combo if e := getUsed(codespace, code); e != nil { panic(fmt.Sprintf("error with code %d is already registered: %q", code, e.desc)) } - err := &Error{codespace: codespace, code: code, desc: description} + err := &Error{codespace: codespace, code: code, desc: description, grpcCode: grpcCode} setUsed(err) return err @@ -60,11 +74,11 @@ func setUsed(err *Error) { // The server (abci app / blockchain) should only refer to registered errors func ABCIError(codespace string, code uint32, log string) error { if e := getUsed(codespace, code); e != nil { - return fmt.Errorf("%s: %w", log, e) + return Wrap(e, log) } // This is a unique error, will never match on .Is() // Use Wrap here to get a stack trace - return fmt.Errorf("%s: %w", log, &Error{codespace: codespace, code: code, desc: "unknown"}) + return Wrap(&Error{codespace: codespace, code: code, desc: "unknown"}, log) } // Error represents a root error. @@ -80,6 +94,7 @@ type Error struct { codespace string code uint32 desc string + grpcCode grpccodes.Code } // New is an alias for Register. @@ -99,22 +114,88 @@ func (e Error) Codespace() string { return e.codespace } -// Wrap extends given error with additional information. +// Is check if given error instance is of a given kind/type. This involves +// unwrapping given error using the Cause method if available. +func (e *Error) Is(err error) bool { + // Reflect usage is necessary to correctly compare with + // a nil implementation of an error. + if e == nil { + return isNilErr(err) + } + + for { + if err == e { + return true + } + + // If this is a collection of errors, this function must return + // true if at least one from the group match. + if u, ok := err.(unpacker); ok { + for _, er := range u.Unpack() { + if e.Is(er) { + return true + } + } + } + + if c, ok := err.(causer); ok { + err = c.Cause() + } else { + return false + } + } +} + +// Wrap extends this error with an additional information. +// It's a handy function to call Wrap with sdk errors. +func (e *Error) Wrap(desc string) error { return Wrap(e, desc) } + +// Wrapf extends this error with an additional information. +// It's a handy function to call Wrapf with sdk errors. +func (e *Error) Wrapf(desc string, args ...interface{}) error { return Wrapf(e, desc, args...) } + +func (e *Error) GRPCStatus() *grpcstatus.Status { + return grpcstatus.Newf(e.grpcCode, "codespace %s code %d: %s", e.codespace, e.code, e.desc) +} + +func isNilErr(err error) bool { + // Reflect usage is necessary to correctly compare with + // a nil implementation of an error. + if err == nil { + return true + } + if reflect.ValueOf(err).Kind() == reflect.Struct { + return false + } + return reflect.ValueOf(err).IsNil() +} + +// Wrap extends given error with an additional information. // -// If the wrapped error does not provide ABCICode method (i.e. stdlib errors), +// If the wrapped error does not provide ABCICode method (ie. stdlib errors), // it will be labeled as internal error. // // If err is nil, this returns nil, avoiding the need for an if statement when -// wrapping an error returned at the end of a function +// wrapping a error returned at the end of a function func Wrap(err error, description string) error { if err == nil { return nil } - return fmt.Errorf("%s: %w", description, err) + // If this error does not carry the stacktrace information yet, attach + // one. This should be done only once per error at the lowest frame + // possible (most inner wrap). + if stackTrace(err) == nil { + err = errors.WithStack(err) + } + + return &wrappedError{ + parent: err, + msg: description, + } } -// Wrapf extends given error with additional information. +// Wrapf extends given error with an additional information. // // This function works like Wrap function with additional functionality of // formatting the input as specified. @@ -122,3 +203,104 @@ func Wrapf(err error, format string, args ...interface{}) error { desc := fmt.Sprintf(format, args...) return Wrap(err, desc) } + +type wrappedError struct { + // This error layer description. + msg string + // The underlying error that triggered this one. + parent error +} + +func (e *wrappedError) Error() string { + return fmt.Sprintf("%s: %s", e.msg, e.parent.Error()) +} + +func (e *wrappedError) Cause() error { + return e.parent +} + +// Is reports whether any error in e's chain matches a target. +func (e *wrappedError) Is(target error) bool { + if e == target { + return true + } + + w := e.Cause() + for { + if w == target { + return true + } + + x, ok := w.(causer) + if ok { + w = x.Cause() + } + if x == nil { + return false + } + } +} + +// Unwrap implements the built-in errors.Unwrap +func (e *wrappedError) Unwrap() error { + return e.parent +} + +// GRPCStatus gets the gRPC status from the wrapped error or returns an unknown gRPC status. +func (e *wrappedError) GRPCStatus() *grpcstatus.Status { + w := e.Cause() + for { + if hasStatus, ok := w.(interface { + GRPCStatus() *grpcstatus.Status + }); ok { + status := hasStatus.GRPCStatus() + return grpcstatus.New(status.Code(), fmt.Sprintf("%s: %s", status.Message(), e.msg)) + } + + x, ok := w.(causer) + if ok { + w = x.Cause() + } + if x == nil { + return grpcstatus.New(grpccodes.Unknown, e.msg) + } + } +} + +// Recover captures a panic and stop its propagation. If panic happens it is +// transformed into a ErrPanic instance and assigned to given error. Call this +// function using defer in order to work as expected. +func Recover(err *error) { + if r := recover(); r != nil { + *err = Wrapf(ErrPanic, "%v", r) + } +} + +// WithType is a helper to augment an error with a corresponding type message +func WithType(err error, obj interface{}) error { + return Wrap(err, fmt.Sprintf("%T", obj)) +} + +// IsOf checks if a received error is caused by one of the target errors. +// It extends the errors.Is functionality to a list of errors. +func IsOf(received error, targets ...error) bool { + if received == nil { + return false + } + for _, t := range targets { + if errors.Is(received, t) { + return true + } + } + return false +} + +// causer is an interface implemented by an error that supports wrapping. Use +// it to test if an error wraps another error instance. +type causer interface { + Cause() error +} + +type unpacker interface { + Unpack() []error +} diff --git a/errors/errors_test.go b/errors/errors_test.go index ce146ea412..54a33e1842 100644 --- a/errors/errors_test.go +++ b/errors/errors_test.go @@ -1,18 +1,231 @@ package errors import ( + stdlib "errors" + "fmt" "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/suite" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" ) -func TestABCIError(t *testing.T) { - if err := ABCIError(testCodespace, 2, "custom"); err.Error() != "custom: tx parse error" { - t.Errorf("expected error message: custom: tx parse error, got: %v", err.Error()) +type errorsTestSuite struct { + suite.Suite +} + +func TestErrorsTestSuite(t *testing.T) { + suite.Run(t, new(errorsTestSuite)) +} + +func (s *errorsTestSuite) SetupSuite() { + s.T().Parallel() +} + +func (s *errorsTestSuite) TestCause() { + std := stdlib.New("this is a stdlib error") + + cases := map[string]struct { + err error + root error + }{ + "Errors are self-causing": { + err: ErrUnauthorized, + root: ErrUnauthorized, + }, + "Wrap reveals root cause": { + err: Wrap(ErrUnauthorized, "foo"), + root: ErrUnauthorized, + }, + "Cause works for stderr as root": { + err: Wrap(std, "Some helpful text"), + root: std, + }, } - if err := ABCIError("unknown", 1, "custom"); err.Error() != "custom: unknown" { - t.Errorf("expected error message: custom: unknown, got: %v", err.Error()) + + for testName, tc := range cases { + s.Require().Equal(tc.root, errors.Cause(tc.err), testName) } } +func (s *errorsTestSuite) TestErrorIs() { + cases := map[string]struct { + a *Error + b error + wantIs bool + }{ + "instance of the same error": { + a: ErrUnauthorized, + b: ErrUnauthorized, + wantIs: true, + }, + "two different coded errors": { + a: ErrUnauthorized, + b: ErrOutOfGas, + wantIs: false, + }, + "successful comparison to a wrapped error": { + a: ErrUnauthorized, + b: Wrap(ErrUnauthorized, "gone"), + wantIs: true, + }, + "unsuccessful comparison to a wrapped error": { + a: ErrUnauthorized, + b: Wrap(ErrInsufficientFee, "too big"), + wantIs: false, + }, + "not equal to stdlib error": { + a: ErrUnauthorized, + b: fmt.Errorf("stdlib error"), + wantIs: false, + }, + "not equal to a wrapped stdlib error": { + a: ErrUnauthorized, + b: Wrap(fmt.Errorf("stdlib error"), "wrapped"), + wantIs: false, + }, + "nil is nil": { + a: nil, + b: nil, + wantIs: true, + }, + "nil is any error nil": { + a: nil, + b: (*customError)(nil), + wantIs: true, + }, + "nil is not not-nil": { + a: nil, + b: ErrUnauthorized, + wantIs: false, + }, + "not-nil is not nil": { + a: ErrUnauthorized, + b: nil, + wantIs: false, + }, + } + for testName, tc := range cases { + s.Require().Equal(tc.wantIs, tc.a.Is(tc.b), testName) + } +} + +func (s *errorsTestSuite) TestIsOf() { + require := s.Require() + + var errNil *Error + err := ErrInvalidAddress + errW := Wrap(ErrLogic, "more info") + + require.False(IsOf(nil), "nil should always have no causer") + require.False(IsOf(nil, err), "nil should always have no causer") + require.False(IsOf(errNil), "nil error should always have no causer") + require.False(IsOf(errNil, err), "nil error should always have no causer") + + require.False(IsOf(err)) + require.False(IsOf(err, nil)) + require.False(IsOf(err, ErrLogic)) + + require.True(IsOf(errW, ErrLogic)) + require.True(IsOf(errW, err, ErrLogic)) + require.True(IsOf(errW, nil, errW), "error should much itself") + err2 := errors.New("other error") + require.True(IsOf(err2, nil, err2), "error should much itself") +} + +type customError struct{} + +func (customError) Error() string { + return "custom error" +} + +func (s *errorsTestSuite) TestWrapEmpty() { + s.Require().Nil(Wrap(nil, "wrapping ")) +} + +func (s *errorsTestSuite) TestWrappedIs() { + require := s.Require() + err := Wrap(ErrTxTooLarge, "context") + require.True(stdlib.Is(err, ErrTxTooLarge)) + + err = Wrap(err, "more context") + require.True(stdlib.Is(err, ErrTxTooLarge)) + + err = Wrap(err, "even more context") + require.True(stdlib.Is(err, ErrTxTooLarge)) + + err = Wrap(ErrInsufficientFee, "...") + require.False(stdlib.Is(err, ErrTxTooLarge)) + + errs := stdlib.New("other") + require.True(stdlib.Is(errs, errs)) + + errw := &wrappedError{"msg", errs} + require.True(errw.Is(errw), "should match itself") + + require.True(stdlib.Is(ErrInsufficientFee.Wrap("wrapped"), ErrInsufficientFee)) + require.True(IsOf(ErrInsufficientFee.Wrap("wrapped"), ErrInsufficientFee)) + require.True(stdlib.Is(ErrInsufficientFee.Wrapf("wrapped"), ErrInsufficientFee)) + require.True(IsOf(ErrInsufficientFee.Wrapf("wrapped"), ErrInsufficientFee)) +} + +func (s *errorsTestSuite) TestWrappedIsMultiple() { + errTest := errors.New("test error") + errTest2 := errors.New("test error 2") + err := Wrap(errTest2, Wrap(errTest, "some random description").Error()) + s.Require().True(stdlib.Is(err, errTest2)) +} + +func (s *errorsTestSuite) TestWrappedIsFail() { + errTest := errors.New("test error") + errTest2 := errors.New("test error 2") + err := Wrap(errTest2, Wrap(errTest, "some random description").Error()) + s.Require().False(stdlib.Is(err, errTest)) +} + +func (s *errorsTestSuite) TestWrappedUnwrap() { + errTest := errors.New("test error") + err := Wrap(errTest, "some random description") + s.Require().Equal(errTest, stdlib.Unwrap(err)) +} + +func (s *errorsTestSuite) TestWrappedUnwrapMultiple() { + errTest := errors.New("test error") + errTest2 := errors.New("test error 2") + err := Wrap(errTest2, Wrap(errTest, "some random description").Error()) + s.Require().Equal(errTest2, stdlib.Unwrap(err)) +} + +func (s *errorsTestSuite) TestWrappedUnwrapFail() { + errTest := errors.New("test error") + errTest2 := errors.New("test error 2") + err := Wrap(errTest2, Wrap(errTest, "some random description").Error()) + s.Require().NotEqual(errTest, stdlib.Unwrap(err)) +} + +func (s *errorsTestSuite) TestABCIError() { + s.Require().Equal("custom: tx parse error", ABCIError(testCodespace, 2, "custom").Error()) + s.Require().Equal("custom: unknown", ABCIError("unknown", 1, "custom").Error()) +} + +func (s *errorsTestSuite) TestGRPCStatus() { + s.Require().Equal(codes.Unknown, grpcstatus.Code(errInternal)) + s.Require().Equal(codes.NotFound, grpcstatus.Code(ErrNotFound)) + + status, ok := grpcstatus.FromError(ErrNotFound) + s.Require().True(ok) + s.Require().Equal("codespace testtesttest code 38: not found", status.Message()) + + // test wrapping + s.Require().Equal(codes.Unimplemented, grpcstatus.Code(ErrNotSupported.Wrap("test"))) + s.Require().Equal(codes.FailedPrecondition, grpcstatus.Code(ErrConflict.Wrapf("test %s", "foo"))) + + status, ok = grpcstatus.FromError(ErrNotFound.Wrap("test")) + s.Require().True(ok) + s.Require().Equal("codespace testtesttest code 38: not found: test", status.Message()) +} + const testCodespace = "testtesttest" var ( @@ -41,5 +254,8 @@ var ( ErrUnknownExtensionOptions = Register(testCodespace, 31, "unknown extension options") ErrPackAny = Register(testCodespace, 33, "failed packing protobuf message to Any") ErrLogic = Register(testCodespace, 35, "internal logic error") + ErrConflict = RegisterWithGRPCCode(testCodespace, 36, codes.FailedPrecondition, "conflict") + ErrNotSupported = RegisterWithGRPCCode(testCodespace, 37, codes.Unimplemented, "feature not supported") + ErrNotFound = RegisterWithGRPCCode(testCodespace, 38, codes.NotFound, "not found") ErrIO = Register(testCodespace, 39, "Internal IO error") ) diff --git a/errors/go.mod b/errors/go.mod index 24e095e7af..9db05a19fc 100644 --- a/errors/go.mod +++ b/errors/go.mod @@ -1,5 +1,23 @@ -module cosmossdk.io/errors/v2 +module cosmossdk.io/errors -// NOTE: this go.mod should have zero dependencies. +go 1.23.0 -go 1.22 +toolchain go1.24.1 + +require ( + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.10.0 + google.golang.org/grpc v1.71.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/sys v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/errors/go.sum b/errors/go.sum index e69de29bb2..43115d64d7 100644 --- a/errors/go.sum +++ b/errors/go.sum @@ -0,0 +1,41 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/errors/handle.go b/errors/handle.go new file mode 100644 index 0000000000..33c3fbfdea --- /dev/null +++ b/errors/handle.go @@ -0,0 +1,12 @@ +package errors + +import "fmt" + +// AssertNil panics on error +// Should be only used with interface methods, which require return error, but the +// error is always nil +func AssertNil(err error) { + if err != nil { + panic(fmt.Errorf("logic error - this should never happen. %w", err)) + } +} diff --git a/errors/stacktrace.go b/errors/stacktrace.go new file mode 100644 index 0000000000..d7021085db --- /dev/null +++ b/errors/stacktrace.go @@ -0,0 +1,120 @@ +package errors + +import ( + "fmt" + "io" + "runtime" + "strings" + + "github.com/pkg/errors" +) + +func matchesFunc(f errors.Frame, prefixes ...string) bool { + fn := funcName(f) + for _, prefix := range prefixes { + if strings.HasPrefix(fn, prefix) { + return true + } + } + return false +} + +// funcName returns the name of this function, if known. +func funcName(f errors.Frame) string { + // this looks a bit like magic, but follows example here: + // https://github.com/pkg/errors/blob/v0.8.1/stack.go#L43-L50 + pc := uintptr(f) - 1 + fn := runtime.FuncForPC(pc) + if fn == nil { + return "unknown" + } + return fn.Name() +} + +func fileLine(f errors.Frame) (string, int) { + // this looks a bit like magic, but follows example here: + // https://github.com/pkg/errors/blob/v0.8.1/stack.go#L14-L27 + // as this is where we get the Frames + pc := uintptr(f) - 1 + fn := runtime.FuncForPC(pc) + if fn == nil { + return "unknown", 0 + } + return fn.FileLine(pc) +} + +func trimInternal(st errors.StackTrace) errors.StackTrace { + // trim our internal parts here + // manual error creation, or runtime for caught panics + for matchesFunc(st[0], + // where we create errors + "cosmossdk.io/errors.Wrap", + "cosmossdk.io/errors.Wrapf", + "cosmossdk.io/errors.WithType", + // runtime are added on panics + "runtime.", + // _test is defined in coverage tests, causing failure + // "/_test/" + ) { + st = st[1:] + } + // trim out outer wrappers (runtime.goexit and test library if present) + for l := len(st) - 1; l > 0 && matchesFunc(st[l], "runtime.", "testing."); l-- { + st = st[:l] + } + return st +} + +func writeSimpleFrame(s io.Writer, f errors.Frame) { + file, line := fileLine(f) + // cut file at "github.com/" + // TODO: generalize better for other hosts? + chunks := strings.SplitN(file, "github.com/", 2) + if len(chunks) == 2 { + file = chunks[1] + } + _, _ = fmt.Fprintf(s, " [%s:%d]", file, line) +} + +// Format works like pkg/errors, with additions. +// %s is just the error message +// %+v is the full stack trace +// %v appends a compressed [filename:line] where the error was created +// +// Inspired by https://github.com/pkg/errors/blob/v0.8.1/errors.go#L162-L176 +func (e *wrappedError) Format(s fmt.State, verb rune) { + // normal output here.... + if verb != 'v' { + _, _ = fmt.Fprint(s, e.Error()) + return + } + // work with the stack trace... whole or part + stack := trimInternal(stackTrace(e)) + if s.Flag('+') { + _, _ = fmt.Fprintf(s, "%+v\n", stack) + _, _ = fmt.Fprint(s, e.Error()) + } else { + _, _ = fmt.Fprint(s, e.Error()) + writeSimpleFrame(s, stack[0]) + } +} + +// stackTrace returns the first found stack trace frame carried by given error +// or any wrapped error. It returns nil if no stack trace is found. +func stackTrace(err error) errors.StackTrace { + type stackTracer interface { + StackTrace() errors.StackTrace + } + + for { + if st, ok := err.(stackTracer); ok { + return st.StackTrace() + } + + if c, ok := err.(causer); ok { + err = c.Cause() + } else { + return nil + } + } +} diff --git a/errors/stacktrace_test.go b/errors/stacktrace_test.go new file mode 100644 index 0000000000..c0a8d6141c --- /dev/null +++ b/errors/stacktrace_test.go @@ -0,0 +1,62 @@ +package errors + +import ( + "errors" + "fmt" + "reflect" + "strings" +) + +func (s *errorsTestSuite) TestStackTrace() { + cases := map[string]struct { + err error + wantError string + }{ + "New gives us a stacktrace": { + err: Wrap(ErrNoSignatures, "name"), + wantError: "name: no signatures supplied", + }, + "Wrapping stderr gives us a stacktrace": { + err: Wrap(fmt.Errorf("foo"), "standard"), + wantError: "standard: foo", + }, + "Wrapping pkg/errors gives us clean stacktrace": { + err: Wrap(errors.New("bar"), "pkg"), + wantError: "pkg: bar", + }, + "Wrapping inside another function is still clean": { + err: Wrap(fmt.Errorf("indirect"), "do the do"), + wantError: "do the do: indirect", + }, + } + + // Wrapping code is unwanted in the errors stack trace. + unwantedSrc := []string{ + "cosmossdk.io/errors.Wrap\n", + "cosmossdk.io/errors.Wrapf\n", + "runtime.goexit\n", + } + const thisTestSrc = "errors/stacktrace_test.go" + + for _, tc := range cases { + s.Require().True(reflect.DeepEqual(tc.err.Error(), tc.wantError)) + s.Require().NotNil(stackTrace(tc.err)) + fullStack := fmt.Sprintf("%+v", tc.err) + s.Require().True(strings.Contains(fullStack, thisTestSrc)) + s.Require().True(strings.Contains(fullStack, tc.wantError)) + + for _, src := range unwantedSrc { + if strings.Contains(fullStack, src) { + s.T().Logf("Stack trace below\n----%s\n----", fullStack) + s.T().Logf("full stack contains unwanted source file path: %q", src) + } + } + + tinyStack := fmt.Sprintf("%v", tc.err) + s.Require().True(strings.HasPrefix(tinyStack, tc.wantError)) + s.Require().False(strings.Contains(tinyStack, "\n")) + // contains a link to where it was created, which must + // be here, not the Wrap() function + s.Require().True(strings.Contains(tinyStack, thisTestSrc)) + } +}