diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..6230d97 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,65 @@ +name: Go + +on: + push: + branches: + - '*' + pull_request: + branches: + - '*' + +jobs: + build: + strategy: + matrix: + go: ['stable', 'oldstable'] + os: ['ubuntu-latest'] + + runs-on: ${{ matrix.os }} + + name: Go ${{ matrix.go }} in ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + name: Install Go + with: + go-version: ${{ matrix.go }} + check-latest: true + cache: true + + - name: Go Environment + run: | + go version + go env + + - name: Get dependencies + run: go get -v -t -d ./... + + - name: Run Fmt + run: go fmt ./... + + - name: Run Vet + run: | + go vet -stdmethods=false $(go list ./...) + go mod tidy + if ! test -z "$(git status --porcelain)"; then + echo "Please run 'go mod tidy'" + exit 1 + fi + + - name: Run Lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + only-new-issues: true + skip-pkg-cache: true + + - name: Run Staticcheck + run: | + go install honnef.co/go/tools/cmd/staticcheck@latest + staticcheck ./... + + - name: Run Test + run: go test -race -coverpkg=./... -coverprofile=coverage.txt ./... \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5b6b95 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Usage: + +## Creation +When creating an error, we can use one of the following ways. All errors created this way will capture the current line and filename. + +```go + // Creation of error with some message. + someError := errkit.New("Sample of std error") + + // Creation of error with some additional details + anotherError := errkit.New("Some error with details", "TextDetail", "Text value", "NumericDetail", 123) +``` + +Sometimes it could be useful to create predefined errors. Such errors will not capture the stack. +```go +var ( + ErrNotFound := errkit.NewSentinelErr("Not found") + ErrAlreadyExists := errkit.NewSentinelErr("Already exists") +) + +func Foo() error { + ... + return ErrNotFound +} +``` + +## Wrapping + +### Adding stack trace +If you are interested in adding information about the line and filename where the sentinel error happened, you can do the following: +```go +func Foo() error { + ... + err := errkit.WithStack(ErrNotFound) + return err +} + +func Bar() error { + err := Foo() + if err != nil && errkit.Is(err, ErrNotFound) { + fmt.Println("Resource not found, do nothing") + return nil + } + ... +} +``` + +### Adding error cause information +Sometimes you might be interested in returning a sentinel error, but also add some cause error to it, in such cases you can do the following: +```go +func FetchSomething(ID string) error { + err := doSomething() // Here we have an error + if err != nil { // At this step we decide that in such a case we'd like to say that the resource is not found + return errkit.WithCause(ErrNotFound, err) + } + + return nil +} + +func FooBar() error { + err := FetchSomething() + if err == nil { + return nil + } + + if errkit.Is(err, ErrNotFound) { + return nil // Not found is an expected scenario here, do nothing + } + + // Errors other than NotFound should be returned as is + return err +} +``` + +### Wrapping an error with a high-level message +Sometimes you might want to add some high-level information to an error before passing it up to the invoker. +```go +func LoadProfile() error { + err := makeAnApiCall() + if err != nil { + return errkit.Wrap(err, "Unable to load profile") + } + return nil +} + +``` + +### Unwrapping errors +If needed, you can always get the wrapped error using the standard errors.Unwrap method, it also has an alias errkit.Unwrap +```go + var ( + ErrSomething = errkit.NewSentinelErr("Some error") + ) + + wrappedError := errkit.Wrap(ErrSomething, "Wrapped error") + + err := errors.Unwrap(wrappedError) + if err != ErrSomething { + return errors.New("Unable to unwrap error cause") + } +``` + +### Matching an errors +You can use standard errors matching methods like errors.Is and errors.As, they also have aliases errkit.Is and errkit.As +```go + // testErrorType which implements std error interface + type testErrorType struct { + message string + } + + var ( + ErrTestType = newTestError("Sample error of custom type") + ) + + wrappedTestError := errkit.Wrap(ErrTestType, "Wrapped TEST error") + if !errors.Is(wrappedTestError, ErrTestType) { + return errors.New("error is not implementing requested type") + } + + var asErr *testErrorType + if !errors.As(origErr, &asErr) { + return errors.New("unable to cast error to its cause") + } +``` diff --git a/error_details.go b/error_details.go new file mode 100644 index 0000000..9a30e2d --- /dev/null +++ b/error_details.go @@ -0,0 +1,50 @@ +package errkit + +import ( + "fmt" +) + +const ( + noVal = "NOVAL" + badKey = "BADKEY" +) + +type ErrorDetails map[string]any + +// ToErrorDetails accepts either an even size array which contains pais of key/value +// or array of one element of ErrorDetails type. +// Result of function is an ErrorDetails +func ToErrorDetails(details []any) ErrorDetails { + if len(details) == 0 { + return nil + } + + if len(details) == 1 { + if dp, ok := details[0].(ErrorDetails); ok { + // Actually we have ErrorDetails on input, so just make a copy + errorDetails := make(ErrorDetails, len(dp)) + for k, v := range dp { + errorDetails[k] = v + } + return errorDetails + } + } + + // It might happen that odd number of elements will be passed, trying our best to handle this case + if len(details)%2 != 0 { + details = append(details, noVal) + } + + errorDetails := make(ErrorDetails, len(details)/2) + for i := 0; i < len(details); i += 2 { + name := details[i] + nameStr, ok := name.(string) + if !ok { + nameStr = fmt.Sprintf("%s:(%v)", badKey, name) + } + + errorDetails[nameStr] = details[i+1] + } + + return errorDetails +} diff --git a/error_details_test.go b/error_details_test.go new file mode 100644 index 0000000..ff75889 --- /dev/null +++ b/error_details_test.go @@ -0,0 +1,51 @@ +package errkit_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/kanisterio/errkit" +) + +type testStruct struct { + Foo string + Bar int + Baz *testStruct +} + +func TestToErrorDetails(t *testing.T) { + cases := []struct { + testName string + args []any + expected errkit.ErrorDetails + }{ + { + testName: "ErrorDetails as an argument", + args: []any{errkit.ErrorDetails{"key": "value"}}, + expected: errkit.ErrorDetails{"key": "value"}, + }, + { + testName: "Sequence of keys and values of any type", + args: []any{"string_key", "string value", "int key", 123, "struct key", testStruct{Foo: "aaa", Bar: 123, Baz: &testStruct{Foo: "bbb", Bar: 234}}}, + expected: errkit.ErrorDetails{"string_key": "string value", "int key": 123, "struct key": testStruct{Foo: "aaa", Bar: 123, Baz: &testStruct{Foo: "bbb", Bar: 234}}}, + }, + { + testName: "Odd number of arguments", + args: []any{"key_1", 1, "key_2"}, + expected: errkit.ErrorDetails{"key_1": 1, "key_2": "NOVAL"}, + }, + { + testName: "Argument which is supposed to be a key is not a string", + args: []any{123, 456}, + expected: errkit.ErrorDetails{"BADKEY:(123)": 456}, + }, + } + + for _, tc := range cases { + t.Run(tc.testName, func(t *testing.T) { + c := qt.New(t) + result := errkit.ToErrorDetails(tc.args) + c.Assert(result, qt.DeepEquals, tc.expected) + }) + } +} diff --git a/error_list.go b/error_list.go new file mode 100644 index 0000000..b33dea8 --- /dev/null +++ b/error_list.go @@ -0,0 +1,103 @@ +package errkit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" +) + +type ErrorList []error + +var _ error = (ErrorList)(nil) +var _ json.Marshaler = (ErrorList)(nil) + +func (e ErrorList) String() string { + sep := "" + var buf bytes.Buffer + buf.WriteRune('[') + for _, err := range e { + buf.WriteString(sep) + sep = "," + buf.WriteString(strconv.Quote(err.Error())) + } + buf.WriteRune(']') + return buf.String() +} + +func (e ErrorList) Error() string { + return e.String() +} + +// As allows error.As to work against any error in the list. +func (e ErrorList) As(target any) bool { + for _, err := range e { + if errors.As(err, target) { + return true + } + } + return false +} + +// Is allows error.Is to work against any error in the list. +func (e ErrorList) Is(target error) bool { + for _, err := range e { + if errors.Is(err, target) { + return true + } + } + return false +} + +func (e ErrorList) MarshalJSON() ([]byte, error) { + var je struct { + Message string `json:"message"` + Errors []json.RawMessage `json:"errors"` + } + + switch len(e) { + case 0: + // no errors + return []byte("null"), nil + case 1: + // this is unlikely to happen as kerrors.Append won't allow having just a single error on the list + je.Message = "1 error has occurred" + default: + je.Message = fmt.Sprintf("%d errors have occurred", len(e)) + } + + je.Errors = make([]json.RawMessage, 0, len(e)) + for i := range e { + raw, err := json.Marshal(jsonMarshable(e[i])) + if err != nil { + return nil, err + } + + je.Errors = append(je.Errors, raw) + } + + return json.Marshal(je) +} + +// Append creates a new combined error from err1, err2. If either error is nil, +// then the other error is returned. +func Append(err1, err2 error) error { + if err1 == nil { + return ErrorList{err2} + } + if err2 == nil { + return ErrorList{err1} + } + el1, ok1 := err1.(ErrorList) + el2, ok2 := err2.(ErrorList) + switch { + case ok1 && ok2: + return append(el1, el2...) + case ok1: + return append(el1, err2) + case ok2: + return append(el2, err1) + } + return ErrorList{err1, err2} +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..4edc764 --- /dev/null +++ b/errors.go @@ -0,0 +1,137 @@ +package errkit + +import ( + "errors" + "fmt" + "runtime" +) + +var _ error = (*errkitError)(nil) +var _ interface { + Unwrap() error +} = (*errkitError)(nil) + +// Make an aliases for errors.Is, errors.As, errors.Unwrap +// To avoid additional imports +var ( + Is = errors.Is + As = errors.As + Unwrap = errors.Unwrap + NewSentinelErr = errors.New +) + +type errkitError struct { + error + cause error + details ErrorDetails + stack []uintptr + callers int +} + +func (e *errkitError) Is(target error) bool { + if target == nil { + return e == target + } + + // Check if the target error is of the same type and value + return errors.Is(e.error, target) +} + +// New returns an error with the given message. +func New(message string, details ...any) error { + return newError(errors.New(message), 2, details...) +} + +// Wrap returns a new errkitError that has the given message and err as the cause. +func Wrap(err error, message string, details ...any) error { + e := newError(errors.New(message), 2, details...) + e.cause = err + return e +} + +// WithStack wraps the given error with a struct that when serialized to JSON will return +// a JSON object with the error message and error stack data. +// +// Returns nil when nil is passed. +// +// var ErrWellKnownError = errors.New("Well-known error") +// ... +// if someCondition { +// return errkit.WithStack(ErrWellKnownError) +// } +func WithStack(err error, details ...any) error { + if err == nil { + return nil + } + + e := newError(err, 2, details...) + return e +} + +// WithCause adds a cause to the given pure error. +// It returns nil when passed error is nil. +// +// Intended for use when a function wants to return a well known error, +// but at the same time wants to add a reason E.g.: +// +// var ErrNotFound = errkit.NewSentinelErr("Resource not found") +// ... +// +// func SomeFunc() error { +// ... +// err := DoSomething() +// if err != nil { +// return errkit.WithCause(ErrNotFound, err) +// } +// ... +// } +func WithCause(err, cause error, details ...any) error { + if err == nil { + return nil + } + + e := newError(err, 2, details...) + e.cause = cause + return e +} + +func newError(err error, stackDepth int, details ...any) *errkitError { + result := &errkitError{ + error: err, + details: ToErrorDetails(details), + stack: make([]uintptr, 1), + } + + result.callers = runtime.Callers(stackDepth+1, result.stack) + + return result +} + +// Unwrap returns the chained causal error, or nil if there is no causal error. +func (e *errkitError) Unwrap() error { + return e.cause +} + +// Message returns the message for this error. +func (e *errkitError) Message() string { + return e.error.Error() +} + +// Details returns the map of details in this error. +func (e *errkitError) Details() ErrorDetails { + return e.details +} + +// Error returns a string representation of the error. +func (e *errkitError) Error() string { + if e.cause == nil { + return e.error.Error() + } + + return fmt.Sprintf("%s: %s", e.error.Error(), e.cause.Error()) +} + +// MarshalJSON is helping json logger to log error in a json format +func (e *errkitError) MarshalJSON() ([]byte, error) { + return MarshalErrkitErrorToJSON(e) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..5d6a736 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,578 @@ +package errkit_test + +import ( + "encoding/json" + "errors" + "fmt" + "runtime" + "strings" + "sync" + "testing" + + "github.com/kanisterio/errkit" + "github.com/kanisterio/errkit/internal/stack" +) + +type testErrorType struct { + message string +} + +func (e *testErrorType) Error() string { + return e.message +} + +func newTestError(msg string) *testErrorType { + return &testErrorType{ + message: msg, + } +} + +var ( + errPredefinedStdError = errors.New("TEST_ERR: Sample of predefined std error") + errPredefinedSentinelError = errkit.NewSentinelErr("TEST_ERR: Sample of sentinel error") + errPredefinedTestError = newTestError("TEST_ERR: Sample error of custom test type") +) + +type Check func(originalErr error, data []byte) error + +func unmarshalJsonError(data []byte, target any) error { + unmarshallingErr := json.Unmarshal(data, target) + if unmarshallingErr != nil { + return fmt.Errorf("Unable to unmarshal error %s\n%s", string(data), unmarshallingErr.Error()) + } + + return nil +} + +func getMessageCheck(msg string) Check { + return func(err error, data []byte) error { + var unmarshalledError struct { + Message string `json:"message,omitempty"` + } + + if e := unmarshalJsonError(data, &unmarshalledError); e != nil { + return e + } + + if unmarshalledError.Message != msg { + return fmt.Errorf("error message does not match the expectd\nexpected: %s\nactual: %s", msg, unmarshalledError.Message) + } + return nil + } +} + +func getTextCheck(msg string) Check { + return func(origError error, _ []byte) error { + if origError.Error() != msg { + return fmt.Errorf("error text does not match the expected\nexpected: %s\nactual: %s", msg, origError.Error()) + } + return nil + } +} + +func filenameCheck(_ error, data []byte) error { + _, filename, _, _ := runtime.Caller(1) + var unmarshalledError struct { + File string `json:"file,omitempty"` + } + if e := unmarshalJsonError(data, &unmarshalledError); e != nil { + return e + } + + if !strings.HasPrefix(unmarshalledError.File, filename) { + return fmt.Errorf("error occured in an unexpected file. expected: %s\ngot: %s", filename, unmarshalledError.File) + } + + return nil +} + +func getStackCheck(fnName string, lineNumber int) Check { + return func(err error, data []byte) error { + e := filenameCheck(err, data) + if e != nil { + return e + } + + var unmarshalledError struct { + LineNumber int `json:"linenumber,omitempty"` + Function string `json:"function,omitempty"` + } + + if e := unmarshalJsonError(data, &unmarshalledError); e != nil { + return e + } + + if unmarshalledError.LineNumber != lineNumber { + return fmt.Errorf("Line number does not match\nexpected: %d\ngot: %d", lineNumber, unmarshalledError.LineNumber) + } + + if unmarshalledError.Function != fnName { + return fmt.Errorf("Function name does not match\nexpected: %s\ngot: %s", fnName, unmarshalledError.Function) + } + + return nil + } +} + +func getErrkitIsCheck(cause error) Check { + return func(origErr error, _ []byte) error { + if !errkit.Is(origErr, cause) { + return errors.New("error is not implementing requested type") + } + + return nil + } +} + +func getUnwrapCheck(expected error) Check { + return func(origErr error, _ []byte) error { + err1 := errors.Unwrap(origErr) + if err1 != expected { + return errors.New("Unable to unwrap error") + } + + return nil + } +} + +func getDetailsCheck(details errkit.ErrorDetails) Check { + return func(_ error, data []byte) error { + var unmarshalledError struct { + Details errkit.ErrorDetails `json:"details,omitempty"` + } + + if e := unmarshalJsonError(data, &unmarshalledError); e != nil { + return e + } + + if len(details) != len(unmarshalledError.Details) { + return errors.New("details don't match") + } + + for k, v := range details { + if unmarshalledError.Details[k] != v { + return errors.New("details don't match") + } + } + + return nil + } +} + +func checkErrorResult(t *testing.T, err error, checks ...Check) { + t.Helper() + + got, e := json.Marshal(err) + if e != nil { + t.Errorf("Error marshaling failed: %s", e.Error()) + return + } + + for _, checker := range checks { + e := checker(err, got) + if e != nil { + t.Errorf("%s", e.Error()) + return + } + } +} + +func TestErrorCreation(t *testing.T) { + t.Run("It should be possible to create sentinel errors", func(t *testing.T) { + e := errPredefinedSentinelError.Error() + if e != "TEST_ERR: Sample of sentinel error" { + t.Errorf("Unexpected result") + } + }) +} + +func TestErrorsWrapping(t *testing.T) { + t.Run("It should be possible to wrap std error, which should be stored as cause", func(t *testing.T) { + wrappedStdError := errkit.Wrap(errPredefinedStdError, "Wrapped STD error") + checkErrorResult(t, wrappedStdError, + getMessageCheck("Wrapped STD error"), // Checking what msg is serialized on top level + getTextCheck("Wrapped STD error: TEST_ERR: Sample of predefined std error"), // Checking what error message is generated + filenameCheck, // Checking callstack capture + getErrkitIsCheck(errPredefinedStdError), // Checking that original error was successfully wrapped + getUnwrapCheck(errPredefinedStdError), // Checking that it's possible to unwrap wrapped error + ) + }) + + t.Run("It should be possible to wrap errkit sentinel error, which should be stored as cause", func(t *testing.T) { + wrappedErrkitError := errkit.Wrap(errPredefinedSentinelError, "Wrapped errkit error") + checkErrorResult(t, wrappedErrkitError, + getMessageCheck("Wrapped errkit error"), // Checking what msg is serialized on top level + getTextCheck("Wrapped errkit error: TEST_ERR: Sample of sentinel error"), + filenameCheck, // Checking callstack capture + getErrkitIsCheck(errPredefinedSentinelError), // Checking that original error was successfully wrapped + ) + }) + + t.Run("It should be possible to wrap errkit error, which should be stored as cause", func(t *testing.T) { + someErrkitError := errkit.New("Original errkit error") + wrappedErrkitError := errkit.Wrap(someErrkitError, "Wrapped errkit error") + checkErrorResult(t, wrappedErrkitError, + getMessageCheck("Wrapped errkit error"), // Checking what msg is serialized on top level + getTextCheck("Wrapped errkit error: Original errkit error"), + filenameCheck, // Checking callstack capture + getErrkitIsCheck(someErrkitError), // Checking that original error was successfully wrapped + ) + }) + + t.Run("It should be possible to wrap custom error implementing error interface, which should be stored as cause", func(t *testing.T) { + wrappedTestError := errkit.Wrap(errPredefinedTestError, "Wrapped TEST error") + checkErrorResult(t, wrappedTestError, + getMessageCheck("Wrapped TEST error"), // Checking what msg is serialized on top level + filenameCheck, // Checking callstack capture + getErrkitIsCheck(errPredefinedTestError), // Checking that original error was successfully wrapped + func(origErr error, _ []byte) error { + var asErr *testErrorType + if errors.As(origErr, &asErr) { + if asErr.Error() == errPredefinedTestError.Error() { + return nil + } + return errors.New("invalid casting of error cause") + } + + return errors.New("unable to cast error to its cause") + }, + ) + }) + + t.Run("It should be possible to wrap predefined error with specific cause", func(t *testing.T) { + errorNotFound := errkit.NewSentinelErr("Resource not found") + cause := errkit.New("Reason why resource not found") + wrappedErr := errkit.WithCause(errorNotFound, cause) + checkErrorResult(t, wrappedErr, + getMessageCheck("Resource not found"), // Check top level msg + getTextCheck("Resource not found: Reason why resource not found"), + filenameCheck, + getErrkitIsCheck(cause), // Check that cause was properly wrapped + getErrkitIsCheck(errorNotFound), // Check that predefined error is also matchable + getUnwrapCheck(cause), // Check that unwrapping of error returns cause + ) + }) + + t.Run("It should be possible to wrap predefined error with specific cause and ErrorDetails", func(t *testing.T) { + errorNotFound := errkit.NewSentinelErr("Resource not found") + cause := errkit.New("Reason why resource not found") + wrappedErr := errkit.WithCause(errorNotFound, cause, "Key", "value") + checkErrorResult(t, wrappedErr, + getMessageCheck("Resource not found"), // Check top level msg + filenameCheck, + getErrkitIsCheck(cause), // Check that cause was properly wrapped + getErrkitIsCheck(errorNotFound), // Check that predefined error is also matchable + getUnwrapCheck(cause), // Check that unwrapping of error returns cause + getDetailsCheck(errkit.ErrorDetails{"Key": "value"}), // Check that details were added + ) + }) + + t.Run("It should still be possible to wrap error created with errkit.New, despite the fact it is unwanted case", func(t *testing.T) { + errorNotFound := errkit.New("Resource not found") + cause := errkit.New("Reason why resource not found") + wrappedErr := errkit.WithCause(errorNotFound, cause) + checkErrorResult(t, wrappedErr, + getMessageCheck("Resource not found"), // Check top level msg + filenameCheck, + getErrkitIsCheck(cause), // Check that cause was properly wrapped + getErrkitIsCheck(errorNotFound), // Check that predefined error is also matchable + getUnwrapCheck(cause), // Check that unwrapping of error returns cause + ) + }) + + t.Run("It should return nil when nil is passed", func(t *testing.T) { + cause := errkit.New("Reason why resource not found") + wrappedErr := errkit.WithCause(nil, cause) + if wrappedErr != nil { + t.Errorf("nil expected to be returned") + } + }) +} + +func TestErrorsWithDetails(t *testing.T) { + // Expecting the following JSON (except stack) for most cases + commonResult := "{\"message\":\"Some error with details\",\"details\":{\"Some numeric detail\":123,\"Some text detail\":\"String value\"}}" + + // Expecting the following JSON (except stack) for special case + oddResult := "{\"message\":\"Some error with details\",\"details\":{\"Some numeric detail\":\"NOVAL\",\"Some text detail\":\"String value\"}}" + invalidKeyResult := "{\"message\":\"Some error with details\",\"details\":{\"BADKEY:(123)\":456,\"Some text detail\":\"String value\"}}" + wrappedResult := "{\"message\":\"Wrapped error\",\"details\":{\"Some numeric detail\":123,\"Some text detail\":\"String value\"},\"cause\":{\"message\":\"TEST_ERR: Sample of sentinel error\"}}" + + getResultCheck := func(expected string) Check { + return func(orig error, _ []byte) error { + b, _ := json.Marshal(orig) + errStr := string(b) + type simplifiedStruct struct { + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` + Cause *simplifiedStruct `json:"cause,omitempty"` + } + var simpl simplifiedStruct + e := json.Unmarshal([]byte(errStr), &simpl) + if e != nil { + return errors.New("unable to unmarshal json representation of an error") + } + + simplStr, e := json.Marshal(simpl) + if e != nil { + return errors.New("unable to marshal simplified error representation to json") + } + + if string(simplStr) != expected { + return fmt.Errorf("serialized error value is not expected: %s\ngot: %s", expected, simplStr) + } + + return nil + } + } + + t.Run("It should be possible to create an error with details", func(t *testing.T) { + err := errkit.New("Some error with details", "Some text detail", "String value", "Some numeric detail", 123) + checkErrorResult(t, err, getResultCheck(commonResult)) + }) + + t.Run("It should be possible to create an error with details using ErrorDetails map", func(t *testing.T) { + err := errkit.New("Some error with details", errkit.ErrorDetails{"Some text detail": "String value", "Some numeric detail": 123}) + checkErrorResult(t, err, getResultCheck(commonResult)) + }) + + t.Run("It should be possible to wrap an error and add details at once", func(t *testing.T) { + err := errkit.Wrap(errPredefinedSentinelError, "Wrapped error", "Some text detail", "String value", "Some numeric detail", 123) + checkErrorResult(t, err, getResultCheck(wrappedResult)) + }) + + t.Run("It should be possible to wrap an error and add details at once using ErrorDetails map", func(t *testing.T) { + err := errkit.Wrap(errPredefinedSentinelError, "Wrapped error", errkit.ErrorDetails{"Some text detail": "String value", "Some numeric detail": 123}) + checkErrorResult(t, err, getResultCheck(wrappedResult)) + }) + + t.Run("It should be possible to create an error with details, even when odd number of values passed", func(t *testing.T) { + err := errkit.New("Some error with details", "Some text detail", "String value", "Some numeric detail") + checkErrorResult(t, err, getResultCheck(oddResult)) + }) + + t.Run("It should be possible to create an error with details, even when detail name is not a string", func(t *testing.T) { + err := errkit.New("Some error with details", "Some text detail", "String value", 123, 456) + checkErrorResult(t, err, getResultCheck(invalidKeyResult)) + }) +} + +func getStackInfo() (string, int) { + fpcs := make([]uintptr, 1) + num := runtime.Callers(2, fpcs) + fn, _, line := stack.GetLocationFromStack(fpcs, num) + return fn, line +} + +func TestErrorsWithStack(t *testing.T) { + t.Run("It should be possible to bind predefined error to current execution location", func(t *testing.T) { + fnName, lineNumber := getStackInfo() + err := errkit.WithStack(errPredefinedTestError) + checkErrorResult(t, err, + getStackCheck(fnName, lineNumber+1), + ) + }) + + t.Run("It should be possible to bind predefined error to current execution location and add some details", func(t *testing.T) { + fnName, lineNumber := getStackInfo() + err := errkit.WithStack(errPredefinedTestError, "Key", "value") + checkErrorResult(t, err, + getStackCheck(fnName, lineNumber+1), + getDetailsCheck(errkit.ErrorDetails{"Key": "value"}), + ) + }) + + t.Run("It should be possible to bind error created with errkit.New, despite the fact it is unwanted case", func(t *testing.T) { + errorNotFound := errkit.New("Resource not found") + fnName, lineNumber := getStackInfo() + err := errkit.WithStack(errorNotFound) + checkErrorResult(t, err, + getMessageCheck("Resource not found"), // Check top level msg + getStackCheck(fnName, lineNumber+1), + getErrkitIsCheck(errorNotFound), // Check that errorNotFound is still matchable + getUnwrapCheck(nil), // Check that we are able to unwrap original error + ) + }) + + t.Run("It should return nil when nil is passed", func(t *testing.T) { + wrappedErr := errkit.WithStack(nil) + if wrappedErr != nil { + t.Errorf("nil expected to be returned") + } + }) +} + +func TestMultipleErrors(t *testing.T) { + t.Run("It should be possible to append errors of different types", func(t *testing.T) { + err1 := errors.New("First error is an stderror") + err2 := newTestError("Second error is a test erorr") + err := errkit.Append(err1, err2) + str := err.Error() + expectedStr := "[\"First error is an stderror\",\"Second error is a test erorr\"]" + + if str != expectedStr { + t.Errorf("Unexpected result.\nexpected: %s\ngot: %s", expectedStr, str) + return + } + }) + + t.Run("It should be possible to use errors.Is and errors.As with error list", func(t *testing.T) { + err := errkit.Append(errPredefinedStdError, errPredefinedTestError) + + if !errors.Is(err, errPredefinedTestError) { + t.Errorf("Predefined error of test error type is not found in an errors list") + return + } + + if !errors.Is(err, errPredefinedStdError) { + t.Errorf("Predefined error of std error type is not found in an errors list") + return + } + + var testErr *testErrorType + if !errors.As(err, &testErr) { + t.Errorf("Unable to reassign error to test type") + return + } + }) + + t.Run("It should NOT be possible to unwrap an error from errors list", func(t *testing.T) { + err := errkit.Append(errPredefinedStdError, errPredefinedTestError) + if errors.Unwrap(err) != nil { + t.Errorf("Unexpected unwrapping result") + return + } + }) + + t.Run("It should be possible to append multiple errkit.errkitError to errors list", func(t *testing.T) { + someErr := errkit.New("Some test error") + err := errkit.Append(errPredefinedSentinelError, someErr) + str := err.Error() + + someErrStr := someErr.Error() + predefinedErrStr := errPredefinedSentinelError.Error() + + arr := append(make([]string, 0), predefinedErrStr, someErrStr) + arrStr, _ := json.Marshal(arr) + + expectedStr := string(arrStr) + + if str != expectedStr { + t.Errorf("unexpected serialized output\nexpected: %s\ngot : %s", expectedStr, str) + return + } + }) + + t.Run("It should return list of errors when trying to append nil to error", func(t *testing.T) { + err1 := errors.New("First error is an stderror") + err2 := newTestError("Second error is a test error") + err := errkit.Append(err1, nil) + str := err.Error() + expectedStr := "[\"First error is an stderror\"]" + + if str != expectedStr { + t.Errorf("Unexpected result.\nexpected: %s\ngot: %s", expectedStr, str) + return + } + + err = errkit.Append(nil, err2) + str = err.Error() + expectedStr = "[\"Second error is a test error\"]" + + if str != expectedStr { + t.Errorf("Unexpected result.\nexpected: %s\ngot: %s", expectedStr, str) + return + } + + err = errkit.Append(err, err1) + str = err.Error() + expectedStr = "[\"Second error is a test error\",\"First error is an stderror\"]" + + if str != expectedStr { + t.Errorf("Unexpected result.\nexpected: %s\ngot: %s", expectedStr, str) + return + } + }) +} + +func TestStackViaGoroutine(t *testing.T) { + t.Run("It should be possible to keep erorr stack when passing an error via goroutine", func(t *testing.T) { + var wg sync.WaitGroup + errCh := make(chan error) + + sentinelErr := errkit.NewSentinelErr("Sentinel error") + + fnName, lineNumber := getStackInfo() + var lock sync.Mutex + var orderedFailures []int + performOperation := func(id int) { + defer wg.Done() + + // Simulate an operation resulting in an error + if id != 2 { + lock.Lock() + defer lock.Unlock() + orderedFailures = append(orderedFailures, id) + errCh <- errkit.WithStack(sentinelErr, "id", id) + } + } + + doOperationsConcurrently := func() error { + // Run operations in goroutines + wg.Add(3) + go performOperation(1) // This operation will fail + go performOperation(2) // This operation will succeed + go performOperation(3) // This operation will fail + + go func() { + wg.Wait() + close(errCh) + }() + var result error + + // Collect errors from the channel + for err := range errCh { + result = errkit.Append(result, err) + } + + return result + } + + err := doOperationsConcurrently() + expectedErrorString := "[\"Sentinel error\",\"Sentinel error\"]" + if err.Error() != expectedErrorString { + t.Errorf("Unexpected result.\nexpected: %s\ngot: %s", expectedErrorString, err.Error()) + return + } + checkErrorResult(t, err, + getMessageCheck("2 errors have occurred"), // Check top level msg + getErrkitIsCheck(sentinelErr), // Check that sentinel error is still matchable + getUnwrapCheck(nil), // Check that we unwrap does not work on error list + ) + + errList, ok := err.(errkit.ErrorList) + if !ok { + t.Errorf("Unexpected error type.") + return + } + + err1 := errList[0] + err2 := errList[1] + + checkErrorResult(t, err1, + getMessageCheck("Sentinel error"), + getStackCheck(fnName+".1", lineNumber+11), + getDetailsCheck(errkit.ErrorDetails{ + "id": float64(orderedFailures[0]), + }), + ) + + checkErrorResult(t, err2, + getMessageCheck("Sentinel error"), + getStackCheck(fnName+".1", lineNumber+11), + getDetailsCheck(errkit.ErrorDetails{ + "id": float64(orderedFailures[1]), + }), + ) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cacfb55 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/kanisterio/errkit + +go 1.21.0 + +require github.com/frankban/quicktest v1.14.6 + +require ( + github.com/google/go-cmp v0.5.9 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ab408ab --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= diff --git a/internal/stack/stack.go b/internal/stack/stack.go new file mode 100644 index 0000000..baa6ea2 --- /dev/null +++ b/internal/stack/stack.go @@ -0,0 +1,23 @@ +package stack + +import ( + "runtime" + "strings" +) + +func GetLocationFromStack(stack []uintptr, callers int) (function, file string, line int) { + if callers < 1 { + // Failure potentially due to wrongly specified depth + return "Unknown", "Unknown", 0 + } + + frames := runtime.CallersFrames(stack[:callers]) + var frame runtime.Frame + frame, _ = frames.Next() + filename := frame.File + if paths := strings.SplitAfterN(frame.File, "/go/src/", 2); len(paths) > 1 { + filename = paths[1] + } + + return frame.Function, filename, frame.Line +} diff --git a/marshable_error.go b/marshable_error.go new file mode 100644 index 0000000..0f1b2df --- /dev/null +++ b/marshable_error.go @@ -0,0 +1,107 @@ +package errkit + +import ( + "encoding" + "encoding/json" + + "github.com/kanisterio/errkit/internal/stack" +) + +type jsonError struct { + Message string `json:"message,omitempty"` + Function string `json:"function,omitempty"` + LineNumber int `json:"linenumber,omitempty"` + File string `json:"file,omitempty"` + Details ErrorDetails `json:"details,omitempty"` + Cause any `json:"cause,omitempty"` +} + +// UnmarshalJSON return error unmarshaled into jsonError. +func (e *jsonError) UnmarshalJSON(source []byte) error { + var parsedError struct { + Message string `json:"message,omitempty"` + Function string `json:"function,omitempty"` + LineNumber int `json:"linenumber,omitempty"` + File string `json:"file,omitempty"` + Details ErrorDetails `json:"details,omitempty"` + Cause json.RawMessage `json:"cause,omitempty"` + } + err := json.Unmarshal(source, &parsedError) + if err != nil { + return err + } + + e.Message = parsedError.Message + e.Function = parsedError.Function + e.File = parsedError.File + e.LineNumber = parsedError.LineNumber + e.Details = parsedError.Details + + if parsedError.Cause == nil { + return nil + } + + // Trying to parse as jsonError + var jsonErrorCause *jsonError + err = json.Unmarshal(parsedError.Cause, &jsonErrorCause) + if err == nil { + e.Cause = jsonErrorCause + return nil + } + + // fallback to any + var cause any + err = json.Unmarshal(parsedError.Cause, &cause) + if err == nil { + e.Cause = cause + } + return err +} + +// jsonMarshable attempts to produce a JSON representation of the given err. +// If the resulting string is empty, then the JSON encoding of the err.Error() +// string is returned or empty if the Error() string cannot be encoded. +func jsonMarshable(err error) any { + if err == nil { + return nil + } + + switch err.(type) { + case json.Marshaler, encoding.TextMarshaler: + return err + default: + // Otherwise wrap the error with {"message":"…"} + return jsonError{Message: err.Error()} + } +} + +func MarshalErrkitErrorToJSON(err *errkitError) ([]byte, error) { + if err == nil { + return nil, nil + } + + function, file, line := stack.GetLocationFromStack(err.stack, err.callers) + + result := jsonError{ + Message: err.Message(), + Function: function, + LineNumber: line, + File: file, + Details: err.Details(), + } + + if err.cause != nil { + if kerr, ok := err.cause.(*errkitError); ok { + causeJSON, err := MarshalErrkitErrorToJSON(kerr) + if err != nil { + return nil, err + } + + result.Cause = json.RawMessage(causeJSON) + } else { + result.Cause = jsonMarshable(err.cause) + } + } + + return json.Marshal(result) +}