From dd6886ae4ab00f10dc7c08174d42df08a792363a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sat, 26 Apr 2025 03:34:37 +0200 Subject: [PATCH 01/19] Add support for Go 1.20 Unwrap, update documentation --- LICENSE | 2 +- README.md | 264 +++++++++++++++++++++++++++------------------ doc.go | 11 +- format.go | 70 ++++++------ multierror.go | 104 +++++++++--------- multierror_test.go | 2 +- panic.go | 44 +++++--- panic_test.go | 4 +- stacktrace.go | 82 ++++++++------ wrapper.go | 26 +++-- xerrors.go | 136 ++++++++++------------- xerrors_test.go | 24 ++--- 12 files changed, 421 insertions(+), 348 deletions(-) diff --git a/LICENSE b/LICENSE index e752b05..b0911f9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Michał Dobaczewski +Copyright (c) 2025 Michał Dobaczewski Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b93c995..8ef4387 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,241 @@ # go-xerrors -`go-xerrors` is an idiomatic and lightweight package that provides a set of functions to make working with errors -easier. It adds support for stack traces, multierrors, and simplifies working with wrapped errors and panics. -The `go-xerrors` package is fully compatible with Go errors 1.13, supporting the `errors.As`, `errors.Is`, -and `errors.Unwrap` functions. +`go-xerrors` is an idiomatic and lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors (multi-errors), and offering flexible error wrapping capabilities. The package also includes utilities for streamlined panic handling. `go-xerrors` maintains full compatibility with Go's standard error handling features (Go 1.13+), including `errors.As`, `errors.Is`, and `errors.Unwrap`. -**Main features:** -- Stack traces -- Multierrors -- More flexible error warping -- Simplified panic handling +**Main Features:** + +- **Stack Traces**: Automatically captures and attaches stack traces to errors upon creation, significantly aiding in debugging and pinpointing the origin of issues. +- **Multi-Errors**: Allows for the aggregation of multiple errors into a single error instance, useful for reporting all failures from operations that involve multiple steps or components. +- **Flexible Error Wrapping**: Provides ways to wrap errors with additional context or messages. Supports wrapping multiple underlying errors simultaneously while preserving the ability to inspect each one individually. +- **Simplified Panic Handling**: Offers functions to convert recovered panic values into standard Go errors, complete with stack traces, facilitating more robust error recovery logic. --- ## Installation -`go get -u github.com/mdobak/go-xerrors` +```bash +go get -u github.com/mdobak/go-xerrors +``` ## Usage -### Basic errors and stack traces +### Basic Errors and Stack Traces -The most useful function in the package is the `xerrors.New` function. This function creates a new error based on the -given message and records the stack trace at the point it was called. - -The simplest use of the `xerrors.New` function is to create a simple string-based error along with a stack trace: +A primary use of `go-xerrors` is creating errors that automatically include a stack trace via the `xerrors.New` function: ```go -err := xerrors.New("access denied") +err := xerrors.New("something went wrong") ``` -However, calling the `Error` method on the returned error will only return the string that was passed to -the `xerrors.New` function. To retrieve a stack trace, the `xerrors.StackTrace` function may be used. This method will -return an `xerrors.Callers` object, which can be represented as a string using the `fmt` package or by using -the `String` method. +Invoking the standard `Error()` method on this `err` returns only the message ("something went wrong"), adhering to the Go convention of providing a concise error description. + +To display the error with its associated stack trace and other potential details, use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` functions. These functions are designed to format errors created by `go-xerrors`, including their detailed information. ```go -trace := xerrors.StackTrace(err) -fmt.Print(trace) +xerrors.Print(err) ``` Output: ``` - at main.TestMain (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1259) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) +Error: something went wrong + at main.main (/home/user/app/main.go:10) + at runtime.main (/usr/local/go/src/runtime/proc.go:225) + at runtime.goexit (/usr/local/go/src/runtime/asm_amd64.s:1371) ``` -Another way to display a stack trace is to use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` methods. These -methods automatically detect whether the specified error contains additional information, such as the stack trace, and -display it along with the error message: +To retrieve only the stack trace information programmatically, use `xerrors.StackTrace`. This function returns an `xerrors.Callers` object, which can be formatted as a string. ```go -xerrors.Print(err) +trace := xerrors.StackTrace(err) +fmt.Print(trace) ``` Output: ``` -Error: access denied at main.TestMain (/home/user/app/main_test.go:10) at testing.tRunner (/home/user/go/src/testing/testing.go:1259) at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) ``` -### Error wrapping +### Sentinel Errors + +Sentinel errors are predefined error values representing specific, known failure conditions (e.g., `io.EOF`). They are typically declared as package-level variables. Using sentinel errors allows for reliable error checking using `errors.Is`, avoiding direct string comparisons of error messages. -The `xerrors.New` function accepts not only strings but also other errors. For example, it can be used to add a stack -trace to sentinel errors. The `xerrors` package provides the `xerrors.Message` function, that creates string-based -sentinel errors, to add a stack trace to them, they need to be passed to the `xerrors.New` function: +`go-xerrors` provides `xerrors.Message` to create distinct sentinel error values with consistent messages. -```jsx +```go var ErrAccessDenied = xerrors.Message("access denied") + // ... -err := xerrors.New(ErrAccessDenied) -``` -Another way to use the `xerrors.New` function is to wrap errors: +func performAction() error { + // ... + return xerrors.New(ErrAccessDenied) // Wrap the sentinel to add a stack trace +} -```jsx -err := xerrors.New("unable to open resource", ErrAccessDenied) -err.Error() // unable to open resource: access denied +// ... + +err := performAction() +if errors.Is(err, ErrAccessDenied) { + log.Println("Operation failed due to access denial.") + xerrors.Print(err) // Prints the error with stack trace +} ``` -It is also possible to wrap an error in another error: +### Error Wrapping + +The `xerrors.New` function can wrap existing errors, which is useful for adding stack traces or providing additional contextual information. + +**Adding Stack Traces to Existing Errors:** + +If you receive an error (like a sentinel error) that lacks a stack trace, you can wrap it using `xerrors.New`: ```go -var ErrAccessDenied = xerrors.Message("access denied") -var ErrResourceOpenFailed = xerrors.Message("unable to open resource") +var ErrResourceBusy = xerrors.Message("resource is busy") + // ... -err := xerrors.New(ErrResourceOpenFailed, ErrAccessDenied) -err.Error() // unable to open resource: access denied -errors.Is(err, ErrResourceOpenFailed) // true -errors.Is(err, ErrAccessDenied) // true +originalErr := checkResourceStatus() // Returns ErrResourceBusy without stack trace +if originalErr != nil { + // Wrap the original error to capture the stack trace at this point + errWithTrace := xerrors.New(originalErr) + if errors.Is(errWithTrace, ErrResourceBusy) { + xerrors.Print(errWithTrace) // Prints "resource is busy" with stack trace + } +} ``` -Unlike the standard `fmt.Errorf` function, `xerrors.New` keeps references to both errors so that no information is lost -during wrapping. - -### Multierrors +**Wrapping Errors with Additional Context:** -Multierrors allow storing a list of errors in a single error, allowing multiple errors to be returned from a function. -It supports the `errors.Is` and `errors.As` methods. However, the `errors.Unwrap` method is not supported. - -To create a new multierror, the function `xerrors.Append` should be used. It works similarly to the append function in -the Go language: +Provide a descriptive string as the first argument to `xerrors.New`, followed by the error(s) to wrap: ```go -var err error -if len(unsername) == 0 { - err = xerrors.Append(err, xerrors.New("username cannot be empty")) -} -if len(password) < 8 { - err = xerrors.Append(err, xerrors.New("password is too short")) +if err := updateUserProfile(user); err != nil { + return xerrors.New("failed to update user profile", err) // failed to update user profile: } ``` -The error list can be displayed in several ways. The simplest way is to use the `Error` method, which will display -errors as a long, one-line string: +**Wrapping Multiple Errors:** + +`xerrors.New` can wrap multiple errors simultaneously: ```go -the following errors occurred: [username cannot be empty, password is too short] +var ErrConnectionFailed = xerrors.Message("connection failed") +var ErrTimeout = xerrors.Message("operation timed out") + +// Wrap both errors under a single contextual message +combinedErr := xerrors.New("data retrieval failed", ErrConnectionFailed, ErrTimeout) + +fmt.Println(combinedErr.Error()) // data retrieval failed: connection failed: operation timed out ``` -Most messages returned by the `Error` method are one-line strings, the `xerrors` package follows this convention. +This feature is useful when a high-level operation fails due to multiple underlying issues that need to be reported together. -Another way is to use one of the following functions: `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint`. The -advantage of using these functions is that they will also display additional details, such as stack traces, and the -error message is much easier to read: +### Multi-Errors (Error Aggregation) +When performing multiple independent operations where several might fail (e.g., validating multiple inputs, processing batch items), use `xerrors.Append` to collect these individual errors into a single multi-error instance. + +`xerrors.Append` behaves like Go's built-in `append` but is specifically designed for aggregating errors. + +```go +var err error + +if input.Username == "" { + // Append creates/adds to the multi-error, including stack trace if using xerrors.New + err = xerrors.Append(err, xerrors.New("username cannot be empty")) +} +if len(input.Password) < 8 { + err = xerrors.Append(err, xerrors.New("password must be at least 8 characters")) +} + +if err != nil { + fmt.Println(err.Error()) // the following errors occurred: [username cannot be empty, password must be at least 8 characters] + + // Detailed output using xerrors.Print: + xerrors.Print(err) + // Output: + // Error: the following errors occurred: [username cannot be empty, password must be at least 8 characters] + // 1. Error: username cannot be empty + // at main.validateInput (/path/to/your/file.go:XX) + // ... stack trace ... + // 2. Error: password must be at least 8 characters + // at main.validateInput (/path/to/your/file.go:YY) + // ... stack trace ... +} ``` -Error: the following errors occurred: [username cannot be empty, password is too short] -1. Error: username cannot be empty - at xerrors.TestFprint (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1439) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1259) -2. Error: password is too short - at xerrors.TestFprint (/home/user/app/main_test.go:13) - at testing.tRunner (/home/user/go/src/testing/testing.go:1439) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1259) -``` -Finally, multierror implements the `xerrors.MultiError` interface, which provides the `Errors` method that returns a -list of errors. +The resulting multi-error implements the standard `error` interface as well as `errors.Is`, `errors.As`, and `errors.Unwrap`, allowing you to check for specific errors or extract them. + +**Comparison with Go 1.20 `errors.Join`:** + +Go 1.20 introduced `errors.Join` for error aggregation. While serving a similar purpose, `xerrors.Append` (especially when used with errors created by `xerrors.New`) offers some differences: -### Recovered panics +1. **Individual Stack Traces**: `go-xerrors` multi-errors preserve the individual stack traces associated with each appended error. `errors.Join` does not inherently manage stack traces for the errors it combines. +2. **Enhanced Formatting**: `xerrors.Print`, `Sprint`, and `Fprint` provide detailed, structured output for multi-errors, listing each constituent error and its stack trace, which can be beneficial for debugging. +3. **Consistent `Error()` Output**: The `Error()` method of a `go-xerrors` multi-error consistently produces a concise, single-line summary. The output format of `errors.Join`'s `Error()` method can vary. -In Go, the values returned by the `recover` built-in do not implement the `error` interface, which may be inconvenient. -For this reason, the package provides two functions to convert recovered panics to errors. +### Simplified Panic Handling -The first function, `xerrors.Recover`, works similarly to the `recover` built-in. This function must always be called -directly using the `defer` keyword. The callback will only be called during a panic, and the provided error will contain -a stack trace: +Go's `recover()` built-in returns the value passed to `panic`, which has type `any` and doesn't directly implement the `error` interface. `go-xerrors` provides utilities to convert these recovered values into proper errors with stack traces. + +**Using `xerrors.Recover`:** + +This function wraps `recover()` and executes a callback function only if a panic occurred. The callback receives the panic value converted to an `error` (with stack trace). Use it directly with `defer`. ```go -defer xerrors.Recover(func (err error) { - xerrors.Print(err) -}) +func handleTask() (err error) { + defer xerrors.Recover(func(err error) { + err = xerrors.FromRecover(r) // Convert recovered value to error with stack trace + log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) + }) + + // ... potentially panicking code ... + panic("task failed") + + return nil +} ``` -The second function allows converting a value returned from `recover` built-in to an error with a stack trace: +**Using `xerrors.FromRecover`:** + +If you prefer the standard `recover()` pattern, use `xerrors.FromRecover` to manually convert the recovered value after checking if `recover()` returned non-nil. ```go -defer func () { - if r := recover(); r != nil { - err := xerrors.FromRecover(r) - xerrors.Print(err) - } -}() +func handleTask() (err error) { + defer func() { + if r := recover(); r != nil { + err := xerrors.FromRecover(r) // Convert recovered value to error with stack trace + log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) + } + }() + + // ... potentially panicking code ... + panic("task failed") + + return nil +} ``` -### Documentation +## API Summary + +Core components of the `go-xerrors` package: + +- `xerrors.New(errors ...any) error`: Creates a new error, automatically capturing a stack trace. Can wrap one or multiple existing errors and optionally add a context message. +- `xerrors.Message(message string) error`: Creates a simple sentinel error value identified by its type and message. +- `xerrors.Append(err error, errs ...error) error`: Aggregates errors. Appends new errors to an existing error (or `nil`), returning a multi-error instance. Preserves stack traces of appended errors. +- `xerrors.Recover(callback func(err error))`: A utility function for use with `defer`. It recovers from panics and invokes the provided callback with the panic value converted to an error (including stack trace). +- `xerrors.FromRecover(recoveredValue any) error`: Converts a value returned by the built-in `recover()` function into an error instance with a stack trace. +- `xerrors.Print(err error)`, `xerrors.Sprint(err error) string`, `xerrors.Fprint(w io.Writer, err error)`: Formatting functions that output errors with detailed information, including stack traces and structured multi-error breakdowns. +- `xerrors.StackTrace(err error) Callers`: Extracts the stack trace information from an error created or wrapped by `go-xerrors`. +- Standard Go compatibility: Fully supports `errors.Is`, `errors.As`, and `errors.Unwrap` for interoperability. + +## Documentation -This package offers a few additional functions and interfaces that may be useful in some use cases. More information -about them can be found in the documentation: +For comprehensive details on all functions and types, please refer to the full documentation available at: [https://pkg.go.dev/github.com/mdobak/go-xerrors](https://pkg.go.dev/github.com/mdobak/go-xerrors) -### License +## License -Licensed under MIT License +Licensed under the MIT License. diff --git a/doc.go b/doc.go index a352abb..3595f6b 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,7 @@ -// Package xerrors is an idiomatic and lightweight package that provides a set -// of functions to make working with errors easier. It adds support for stack -// traces, multierrors, and simplifies working with wrapped errors and panics. -// The `go-xerrors` package is fully compatible with Go errors 1.13, supporting -// the `errors.As`, `errors.Is`, and `errors.Unwrap` functions. +// Package xerrors is an idiomatic and lightweight package that +// provides a set of functions to make working with errors easier. +// It adds support for stack traces, [multierror]s, and simplifies +// working with wrapped errors and panics. The `go-xerrors` package +// is fully compatible with Go errors 1.13, supporting the +// `errors.As`, `errors.Is`, and `errors.Unwrap` functions. package xerrors diff --git a/format.go b/format.go index 3d2fab5..0360607 100644 --- a/format.go +++ b/format.go @@ -11,76 +11,80 @@ import ( var errWriter io.Writer = os.Stderr -// Print formats an error and displays it on stderr. +// Print writes a formatted error to stderr. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// If the error implements the [ErrorDetails] interface, the result +// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// standard Error method is used. The formatted error can span +// multiple lines and always ends with a newline. func Print(err error) { fprint(errWriter, err) } -// Sprint formats an error and returns it as a string. +// Sprint returns a formatted error as a string. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// If the error implements the [ErrorDetails] interface, the result +// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// standard Error method is used. The formatted error can span +// multiple lines and always ends with a newline. func Sprint(err error) string { s := &strings.Builder{} fprint(s, err) return s.String() } -// Fprint formats an error. +// Fprint writes a formatted error to the provided [io.Writer]. // -// If the error implements the DetailedError interface, the result from the -// ErrorDetails method is used for each wrapped error, otherwise the standard -// Error method is used. A formatted error can be multi-line and always ends -// with a newline. +// If the error implements the [ErrorDetails] interface, the result +// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// standard Error method is used. The formatted error can span +// multiple lines and always ends with a newline. func Fprint(w io.Writer, err error) (int, error) { return fprint(w, err) } +// fprint is a helper function that writes the formatted error to the +// given [io.Writer]. func fprint(w io.Writer, e error) (n int, err error) { const firstErrorPrefix = "Error: " const previousErrorPrefix = "Previous error: " - b := &bytes.Buffer{} - f := true + var buffer bytes.Buffer + first := true for e != nil { switch terr := e.(type) { - case DetailedError: - if f { - b.WriteString(firstErrorPrefix) + case ErrorDetails: + if first { + buffer.WriteString(firstErrorPrefix) } else { - b.WriteString(previousErrorPrefix) + buffer.WriteString(previousErrorPrefix) } - b.WriteString(terr.Error()) - b.WriteByte('\n') - b.WriteString(terr.ErrorDetails()) + buffer.WriteString(terr.Error()) + buffer.WriteByte('\n') + buffer.WriteString(terr.ErrorDetails()) default: - // If an error does not implement the DetailedError interface, + // If an error does not implement the ErrorDetails interface, // then the Error() method will print all errors separated // with ":", so there is no need to render each error other than // the first one. - if f { - b.WriteString(firstErrorPrefix) - b.WriteString(terr.Error()) - b.WriteByte('\n') + if first { + buffer.WriteString(firstErrorPrefix) + buffer.WriteString(terr.Error()) + buffer.WriteByte('\n') } } - f = false - if we, ok := e.(Wrapper); ok { + first = false + if we, ok := e.(unwrapper); ok { e = we.Unwrap() continue } break } - return w.Write(b.Bytes()) + return w.Write(buffer.Bytes()) } -func format(s fmt.State, verb rune, v interface{}) { +// format is a helper function to format custom types when +// implementing [fmt.Formatter]. +func format(s fmt.State, verb rune, v any) { f := []rune{'%'} for _, c := range []int{'-', '+', '#', ' ', '0'} { if s.Flag(c) { diff --git a/multierror.go b/multierror.go index e0cca2f..f5e51bf 100644 --- a/multierror.go +++ b/multierror.go @@ -6,58 +6,54 @@ import ( "strings" ) -// Append adds more errors to an existing list of errors. If err is not a list -// of errors, then it will be converted into a list. Nil errors are ignored. -// It does not record a stack trace. -// -// If the list is empty, nil is returned. If the list contains only one error, -// that error is returned instead of list. +const multiErrorErrorPrefix = "the following errors occurred: " + +// Append appends the provided errors to an existing error or list of +// errors. If `err` is not a [multiError], it will be converted into +// one. Nil errors are ignored. It does not record a stack trace. // -// The returned list of errors is compatible with Go 1.13 errors, and it -// supports the errors.Is and errors.As methods. However, the errors.Unwrap -// method is not supported. +// If the resulting error list is empty, nil is returned. If the +// resulting error list contains only one error, that error is +// returned instead of the list. // -// Append is not thread-safe. +// The returned error is compatible with Go errors, supporting +// [errors.Is], [errors.As], and the Go 1.20 `Unwrap() []error` +// method. func Append(err error, errs ...error) error { - if err == nil && len(errs) == 0 { - return nil - } - switch errTyp := err.(type) { - case multiError: - for _, e := range errs { - if e != nil { - errTyp = append(errTyp, e) - } - } - return errTyp - default: - var me multiError - if err != nil { - me = multiError{err} - } - for _, e := range errs { - if e != nil { - me = append(me, e) + var me multiError + if err != nil { + if merr, ok := err.(multiError); ok { + for _, e := range merr { + if e != nil { + me = append(me, e) + } } + } else { + me = append(me, err) } - if len(me) == 1 { - return me[0] - } - if len(me) == 0 { - return nil + } + for _, e := range errs { + if e != nil { + me = append(me, e) } + } + switch len(me) { + case 0: + return nil + case 1: + return me[0] + default: return me } } -const multiErrorErrorPrefix = "the following errors occurred: " - -// multiError is a slice of errors that can be used as a single error. +// multiError is a slice of errors that can be treated as a single +// error. type multiError []error -// Error implements the error interface. +// Error implements the [error] interface. func (e multiError) Error() string { - s := &strings.Builder{} + var s strings.Builder s.WriteString(multiErrorErrorPrefix) s.WriteString("[") for n, err := range e { @@ -70,10 +66,10 @@ func (e multiError) Error() string { return s.String() } -// ErrorDetails implements the DetailedError interface. +// ErrorDetails implements the [ErrorDetails] interface. func (e multiError) ErrorDetails() string { - s := &strings.Builder{} - for n, err := range e.Errors() { + var s strings.Builder + for n, err := range e.Unwrap() { s.WriteString(strconv.Itoa(n + 1)) s.WriteString(". ") s.WriteString(indent(Sprint(err))) @@ -81,14 +77,17 @@ func (e multiError) ErrorDetails() string { return s.String() } -// Errors implements the MultiError interface. -func (e multiError) Errors() []error { +// Unwrap implements the Go 1.20 `Unwrap() []error` method, returning +// a slice containing all errors in the list. +func (e multiError) Unwrap() []error { s := make([]error, len(e)) copy(s, e) return s } -func (e multiError) As(target interface{}) bool { +// As implements the Go 1.13 `errors.As` method, allowing type +// assertions on all errors in the list. +func (e multiError) As(target any) bool { for _, err := range e { if errors.As(err, target) { return true @@ -97,6 +96,8 @@ func (e multiError) As(target interface{}) bool { return false } +// Is implements the Go 1.13 `errors.Is` method, allowing +// comparisons with all errors in the list. func (e multiError) Is(target error) bool { for _, err := range e { if errors.Is(err, target) { @@ -106,12 +107,15 @@ func (e multiError) Is(target error) bool { return false } -// indent idents every line, except the first one, with tab. +// indent indents every line, except the first one, with a tab. func indent(s string) string { - end := "" - if strings.HasSuffix(s, "\n") { - end = "\n" + nl := strings.HasSuffix(s, "\n") + if nl { s = s[:len(s)-1] } - return strings.ReplaceAll(s, "\n", "\n\t") + end + s = strings.ReplaceAll(s, "\n", "\n\t") + if nl { + s += "\n" + } + return s } diff --git a/multierror_test.go b/multierror_test.go index 58d2830..eef26dd 100644 --- a/multierror_test.go +++ b/multierror_test.go @@ -20,7 +20,7 @@ func TestAppend(t *testing.T) { {err: Message("a"), errs: nil, want: "a"}, {err: multiError{Message("a"), Message("b")}, errs: nil, want: "the following errors occurred: [a, b]"}, {err: multiError{Message("a"), Message("b")}, errs: []error{Message("c")}, want: "the following errors occurred: [a, b, c]"}, - {err: multiError{}, errs: []error{Message("a"), nil}, want: "the following errors occurred: [a]"}, + {err: multiError{}, errs: []error{Message("a"), nil}, want: "a"}, {err: nil, errs: nil, wantNil: true}, {err: nil, errs: []error{nil, nil}, wantNil: true}, } diff --git a/panic.go b/panic.go index 47cd573..fd917e1 100644 --- a/panic.go +++ b/panic.go @@ -4,12 +4,23 @@ import ( "fmt" ) -// Recover wraps the recover() built-in and converts a value returned by it to -// an error with a stack trace. The fn callback will be invoked only during -// panicking. +// PanicError represents an error that occurs during a panic. It is +// returned by the [Recover] and [FromRecover] functions. It provides +// access to the original panic value via the [Panic] method. +type PanicError interface { + error + + // Panic returns the value that caused the panic. + Panic() any +} + +// Recover wraps the built-in `recover()` function, converting the +// recovered value into an error with a stack trace. The provided `fn` +// callback is only invoked when a panic occurs. The error passed to +// `fn` implements [PanicError]. // -// This function must always be used *directly* with the "defer" keyword. -// Otherwise, it will not work. +// This function must always be used directly with the `defer` +// keyword; otherwise, it will not function correctly. func Recover(fn func(err error)) { if r := recover(); r != nil { fn(&withStackTrace{ @@ -19,12 +30,13 @@ func Recover(fn func(err error)) { } } -// FromRecover takes the result of the recover() built-in and converts it to -// an error with a stack trace. +// FromRecover converts the result of the built-in `recover()` into +// an error with a stack trace. The returned error implements +// [PanicError]. Returns nil if `r` is nil. // -// This function must be invoked in the same function as recover(), otherwise -// the returned stack trace will not be correct. -func FromRecover(r interface{}) error { +// This function must be called in the same function as `recover()` +// to ensure the stack trace is accurate. +func FromRecover(r any) error { if r == nil { return nil } @@ -34,18 +46,18 @@ func FromRecover(r interface{}) error { } } -// panicError is an error constructed from a value returned by the recover() -// built-in during panicking. +// panicError represents an error that occurs during a panic, +// constructed from the value returned by `recover()`. type panicError struct { - panic interface{} + panic any } -// Panic returns the value from the recover() function. -func (e *panicError) Panic() interface{} { +// Panic implements the [PanicError] interface. +func (e *panicError) Panic() any { return e.panic } -// Error implements the error interface. +// Error implements the [error] interface. func (e *panicError) Error() string { return fmt.Sprintf("panic: %v", e.panic) } diff --git a/panic_test.go b/panic_test.go index a957112..4d4d9ec 100644 --- a/panic_test.go +++ b/panic_test.go @@ -9,7 +9,7 @@ import ( func TestRecover(t *testing.T) { tests := []struct { - panic interface{} + panic any want string }{ {panic: nil, want: ""}, @@ -53,7 +53,7 @@ func TestRecover(t *testing.T) { func TestFromRecover(t *testing.T) { tests := []struct { - panic interface{} + panic any want string }{ {panic: nil, want: ""}, diff --git a/stacktrace.go b/stacktrace.go index 90e05d2..74a9a72 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -10,14 +10,14 @@ import ( const stackTraceDepth = 32 -// StackTrace returns a stack trace from given error or the first stack trace -// from the wrapped errors. +// StackTrace extracts the stack trace from the given error or the +// first wrapped error that implements [stackTracer]. func StackTrace(err error) Callers { for err != nil { - if e, ok := err.(StackTracer); ok { + if e, ok := err.(stackTracer); ok { return e.StackTrace() } - if e, ok := err.(Wrapper); ok { + if e, ok := err.(unwrapper); ok { err = e.Unwrap() continue } @@ -26,13 +26,11 @@ func StackTrace(err error) Callers { return nil } -// WithStackTrace adds a stack trace to the error at the point it was called. -// The skip argument is the number of stack frames to skip. +// WithStackTrace wraps the provided error with a stack trace, +// capturing the stack at the point of the call. The `skip` argument +// specifies how many stack frames to skip. // -// This function is useful when you want to skip the first few frames in a -// stack trace. To add a stack trace to a sentinel error, use the New function. -// -// If err is nil, then nil is returned. +// If err is nil, WithStackTrace returns nil. func WithStackTrace(err error, skip int) error { if err == nil { return nil @@ -43,55 +41,58 @@ func WithStackTrace(err error, skip int) error { } } -// withStackTrace adds a stack trace to en error. +// withStackTrace wraps an error with a captured stack trace. type withStackTrace struct { err error stack Callers } -// Error implements the error interface. +// Error implements the [error] interface. func (e *withStackTrace) Error() string { return e.err.Error() } -// ErrorDetails implements the DetailedError interface. +// ErrorDetails implements the [ErrorDetails] interface. func (e *withStackTrace) ErrorDetails() string { return e.stack.String() } -// Unwrap implements the Wrapper interface. +// Unwrap implements the Go 1.13 `Unwrap() []error` method, returning +// the wrapped error. func (e *withStackTrace) Unwrap() error { return e.err } -// StackTrace implements the StackTracer interface. +// StackTrace returns the stack trace captured at the point of the +// error creation. func (e *withStackTrace) StackTrace() Callers { return e.stack } +// Frame represents a single stack frame with file, line, and +// function details. type Frame struct { File string Line int Function string } -// String implements the fmt.Stringer interface. +// String implements the [fmt.Stringer] interface. func (f Frame) String() string { s := &strings.Builder{} f.writeFrame(s) return s.String() } -// Format implements the fmt.Formatter interface. -// -// The verbs: +// Format implements the [fmt.Formatter] interface. // -// %s function, file and line number in a single line -// %f filename -// %d line number -// %n function name, the plus flag adds a package name -// %v same as %s, the plus or hash flags print struct details -// %q a double-quoted Go string with same contents as %s +// Supported verbs: +// - %s function, file, and line number in a single line +// - %f filename +// - %d line number +// - %n function name, with '+' flag adding the package name +// - %v same as %s; '+' or '#' flags print struct details +// - %q double-quoted Go string, same as %s func (f Frame) Format(s fmt.State, verb rune) { type _Frame Frame switch verb { @@ -122,6 +123,7 @@ func (f Frame) Format(s fmt.State, verb rune) { } } +// writeFrame writes a formatted stack frame to the given [io.Writer]. func (f Frame) writeFrame(w io.Writer) { io.WriteString(w, "\tat ") io.WriteString(w, shortname(f.Function)) @@ -132,10 +134,12 @@ func (f Frame) writeFrame(w io.Writer) { io.WriteString(w, ")") } -// Callers is a list of program counters returned by the runtime.Callers. +// Callers represents a list of program counters from the +// [runtime.Callers] function. type Callers []uintptr -// Frames returns a slice of structures with a function/file/line information. +// Frames returns a slice of [Frame] structs with function, file, and +// line information. func (c Callers) Frames() []Frame { r := make([]Frame, len(c)) f := runtime.CallersFrames(c) @@ -155,20 +159,19 @@ func (c Callers) Frames() []Frame { return r } -// String implements the fmt.Stringer interface. +// String implements the [fmt.Stringer] interface. func (c Callers) String() string { s := &strings.Builder{} c.writeTrace(s) return s.String() } -// Format implements the fmt.Formatter interface. -// -// The verbs: +// Format implements the [fmt.Formatter] interface. // -// %s a stack trace -// %v same as %s, the plus or hash flags print struct details -// %q a double-quoted Go string with same contents as %s +// Supported verbs: +// - %s complete stack trace +// - %v same as %s; '+' or '#' flags print struct details +// - %q double-quoted Go string, same as %s func (c Callers) Format(s fmt.State, verb rune) { type _Callers Callers switch verb { @@ -188,6 +191,7 @@ func (c Callers) Format(s fmt.State, verb rune) { } } +// writeTrace writes the stack trace to the provided [io.Writer]. func (c Callers) writeTrace(w io.Writer) { frames := c.Frames() for _, frame := range frames { @@ -196,12 +200,22 @@ func (c Callers) writeTrace(w io.Writer) { } } +// stackTracer represents an error that provides a stack trace. +type stackTracer interface { + error + StackTrace() Callers +} + +// callers captures the current stack trace, skipping the specified +// number of frames. func callers(skip int) Callers { b := make([]uintptr, stackTraceDepth) l := runtime.Callers(skip+2, b[:]) return b[:l] } +// shortname extracts the short name of a function, removing the +// package path. func shortname(name string) string { i := strings.LastIndex(name, "/") return name[i+1:] diff --git a/wrapper.go b/wrapper.go index 8ecb61c..7d5f836 100644 --- a/wrapper.go +++ b/wrapper.go @@ -5,16 +5,15 @@ import ( "strings" ) -// WithWrapper wraps err with wrapper. +// WithWrapper wraps `err` with a `wrapper` error. // -// The error used as wrapper should be a simple error, preferably a sentinel -// error. This is because details such as the wrapper's stack trace are ignored. +// The `wrapper` should generally be a simple, sentinel error, as +// details like its stack trace are ignored. The `Unwrap` method +// will only unwrap `err`, but [errors.Is] and [errors.As] work +// with both `wrapper` and `err`. // -// The Unwrap method will unwrap only err but errors.Is, errors.As works with -// both of the errors. -// -// If wrapper is nil, then err is returned. -// If err is nil, then nil is returned. +// If `wrapper` is nil, `err` is returned. If `err` is nil, +// WithWrapper returns nil. func WithWrapper(wrapper error, err error) error { if err == nil { return nil @@ -34,7 +33,7 @@ type withWrapper struct { err error } -// Error implements the error interface. +// Error implements the [error] interface. func (e *withWrapper) Error() string { s := &strings.Builder{} s.WriteString(e.wrapper.Error()) @@ -43,15 +42,20 @@ func (e *withWrapper) Error() string { return s.String() } -// Unwrap implements the Wrapper interface. +// Unwrap implements the Go 1.13 `Unwrap() []error` method, returning +// the wrapped error. func (e *withWrapper) Unwrap() error { return e.err } -func (e *withWrapper) As(target interface{}) bool { +// As implements the Go 1.13 `errors.As` method, allowing type +// assertions on all errors in the list. +func (e *withWrapper) As(target any) bool { return errors.As(e.wrapper, target) || errors.As(e.err, target) } +// Is implements the Go 1.13 `errors.Is` method, allowing +// comparisons with all errors in the list. func (e *withWrapper) Is(target error) bool { return errors.Is(e.wrapper, target) || errors.Is(e.err, target) } diff --git a/xerrors.go b/xerrors.go index 66c2134..c4ddf69 100644 --- a/xerrors.go +++ b/xerrors.go @@ -4,94 +4,52 @@ import ( "fmt" ) -// Wrapper provides context around another error. -type Wrapper interface { - error - Unwrap() error -} - -// StackTracer provides a stack trace for an error. -type StackTracer interface { - error - StackTrace() Callers -} - -// MultiError is an error that contains multiple errors. -type MultiError interface { - error - Errors() []error -} - -// DetailedError provides extended information about an error. -// The ErrorDetails method returns a longer, multi-line description of -// the error. It always ends with a new line. -type DetailedError interface { +// ErrorDetails represents an error that provides additional details +// beyond the error message. +// +// The ErrorDetails method returns a longer, multi-line description +// of the error. It always ends with a new line. +type ErrorDetails interface { error ErrorDetails() string } -// messageError is the simplest possible error that contains only -// a string message. -type messageError struct { - msg string -} - -// Error implements the error interface. -func (e *messageError) Error() string { - return e.msg -} - -// Message creates a simple error with the given message. It does not record -// a stack trace. Each call returns a distinct error value even if the -// message is identical. +// Message creates a simple error with the given message, without +// recording a stack trace. Each call returns a distinct error +// instance, even if the message is identical. // -// This function is intended to create sentinel errors, sometimes referred -// to as "constant errors". +// This function is useful for creating sentinel errors, often +// referred to as "constant errors." func Message(msg string) error { return &messageError{msg: msg} } -// New creates a new error from the given value and records a stack trace at -// the point it was called. If multiple values are provided, then each error -// is wrapped by the previous error. Calling New(a, b, c), where a, b, and c -// are errors, is equivalent to calling New(WithWrapper(WithWrapper(a, b), c)). -// -// This function may be used to: -// -// - Add a stack trace to an error: New(err) -// -// - Create a message error with a stack trace: New("access denied") -// -// - Wrap an error with a message: New("access denied", io.EOF) -// -// - Wrap one error in another: New(ErrAccessDenied, io.EOF) -// -// - Add a message to a sentinel error: New(ErrReadError, "access denied") -// -// Values are converted to errors according to the following rules: -// -// - If a value is an error, it will be used as is. -// -// - If a value is a string, then new error with a given string as a message -// will be created. -// -// - If a value is nil, it will be ignored. -// -// - If a value implements the fmt.Stringer interface, then a String() method -// will be used to create an error. -// -// - For other types the result of fmt.Sprint will be used to create a message -// error. -// -// It is possible to use errors.Is function on returned error to check whether -// an error has been used in the New function. -// -// If the function is called with no arguments or all arguments are nil, it -// returns nil. -// -// To create a simple message error without a stack trace to be used as a -// sentinel error, use the Message function instead. -func New(vals ...interface{}) error { +// New creates a new error from the provided values and records a +// stack trace at the point of the call. If multiple values are +// provided, each value is wrapped by the previous one, forming a +// chain of errors. +// +// Usage examples: +// - Add a stack trace to an existing error: New(err) +// - Create an error with a message and a stack trace: New("access denied") +// - Wrap an error with a message: New("access denied", io.EOF) +// - Add context to a sentinel error: New(ErrReadError, "access denied") +// +// Conversion rules for arguments: +// - If the value is an error, it is used as is. +// - If the value is a string, a new error with that message is +// created. +// - If the value implements [fmt.Stringer], the result of +// String() is used to create an error. +// - If the value is nil, it is ignored. +// - Otherwise, the result of [fmt.Sprint] is used to create an +// error. +// +// If called with no arguments or only nil values, New returns nil. +// +// For simple errors without a stack trace, use the [Message] +// function. +func New(vals ...any) error { var errs error for _, val := range vals { if val == nil { @@ -116,7 +74,25 @@ func New(vals ...interface{}) error { } } -func toError(val interface{}) error { +// unwrapper represents an error that wraps another error, providing +// additional context. +type unwrapper interface { + error + Unwrap() error +} + +// messageError represents a simple error that contains only a string +// message. +type messageError struct { + msg string +} + +// Error implements the [error] interface. +func (e *messageError) Error() string { + return e.msg +} + +func toError(val any) error { var err error switch typ := val.(type) { case error: diff --git a/xerrors_test.go b/xerrors_test.go index b78dfa7..be1ae95 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -36,21 +36,21 @@ func TestMessage(t *testing.T) { func TestNew(t *testing.T) { tests := []struct { - vals []interface{} + vals []any want string wantNil bool }{ - {vals: []interface{}{""}, want: ""}, - {vals: []interface{}{"foo", "bar"}, want: "foo: bar"}, - {vals: []interface{}{nil, "foo", "bar"}, want: "foo: bar"}, - {vals: []interface{}{"foo", nil, "bar"}, want: "foo: bar"}, - {vals: []interface{}{Message("foo"), Message("bar")}, want: "foo: bar"}, - {vals: []interface{}{io.EOF, io.EOF}, want: "EOF: EOF"}, - {vals: []interface{}{stringer{s: "foo"}, stringer{s: "bar"}}, want: "foo: bar"}, - {vals: []interface{}{42, 314}, want: "42: 314"}, - {vals: []interface{}{}, wantNil: true}, - {vals: []interface{}{nil}, wantNil: true}, - {vals: []interface{}{nil, nil}, wantNil: true}, + {vals: []any{""}, want: ""}, + {vals: []any{"foo", "bar"}, want: "foo: bar"}, + {vals: []any{nil, "foo", "bar"}, want: "foo: bar"}, + {vals: []any{"foo", nil, "bar"}, want: "foo: bar"}, + {vals: []any{Message("foo"), Message("bar")}, want: "foo: bar"}, + {vals: []any{io.EOF, io.EOF}, want: "EOF: EOF"}, + {vals: []any{stringer{s: "foo"}, stringer{s: "bar"}}, want: "foo: bar"}, + {vals: []any{42, 314}, want: "42: 314"}, + {vals: []any{}, wantNil: true}, + {vals: []any{nil}, wantNil: true}, + {vals: []any{nil, nil}, wantNil: true}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { From 5af8ad70df9f3060d8e8027922014000fa228278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sat, 26 Apr 2025 03:41:37 +0200 Subject: [PATCH 02/19] Update GitHub workflow --- .github/workflows/go.yaml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 8c82e75..5ad64d5 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -2,22 +2,20 @@ name: Go on: push: - branches: [ "master" ] pull_request: - branches: [ "master" ] jobs: CI: strategy: matrix: - go_version: [ "1.13.x", "1.18.x" ] + go_version: [ "1.13.x", "1.24.x" ] runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v3" + - uses: "actions/checkout@v4" - name: "Set up Go" - uses: "actions/setup-go@v3" + uses: "actions/setup-go@v5" with: go-version: ${{ matrix.go_version }} @@ -28,7 +26,6 @@ jobs: run: "go test -v ./..." - name: "Linter" - uses: "golangci/golangci-lint-action@v3" + uses: "golangci/golangci-lint-action@v7" with: - version: "v1.48" - + version: "v2.0" From a09b13b150369b23a26c074e60e6b200ed630483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sat, 26 Apr 2025 03:43:08 +0200 Subject: [PATCH 03/19] Bump minimum Go version to 1.18 --- .github/workflows/go.yaml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 5ad64d5..376ab29 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -8,7 +8,7 @@ jobs: CI: strategy: matrix: - go_version: [ "1.13.x", "1.24.x" ] + go_version: [ "1.18.x", "1.24.x" ] runs-on: "ubuntu-latest" steps: diff --git a/go.mod b/go.mod index 3787d9a..64df58a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/mdobak/go-xerrors -go 1.13 +go 1.18 From 6497bddd37aba4a75a1ff31be308e47a71078c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sat, 26 Apr 2025 03:44:48 +0200 Subject: [PATCH 04/19] Update golangci-lint config --- .golangci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index c74b857..387c5e4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,4 @@ +version: "2" linters: disable: - - "errcheck" + - errcheck From 2e5f512e9ba0d5989da80846c0e6bbb877bb5002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sat, 26 Apr 2025 18:46:59 +0200 Subject: [PATCH 05/19] Update documentation --- README.md | 28 ++++++++++------------------ doc.go | 12 ++++++------ stacktrace.go | 2 +- wrapper.go | 2 +- 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8ef4387..03f4ff7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-xerrors -`go-xerrors` is an idiomatic and lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors (multi-errors), and offering flexible error wrapping capabilities. The package also includes utilities for streamlined panic handling. `go-xerrors` maintains full compatibility with Go's standard error handling features (Go 1.13+), including `errors.As`, `errors.Is`, and `errors.Unwrap`. +`go-xerrors` is an idiomatic and lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying working with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (Go 1.13+), including `errors.As`, `errors.Is`, and `errors.Unwrap`. **Main Features:** @@ -72,7 +72,7 @@ var ErrAccessDenied = xerrors.Message("access denied") func performAction() error { // ... - return xerrors.New(ErrAccessDenied) // Wrap the sentinel to add a stack trace + return ErrAccessDenied } // ... @@ -80,7 +80,6 @@ func performAction() error { err := performAction() if errors.Is(err, ErrAccessDenied) { log.Println("Operation failed due to access denial.") - xerrors.Print(err) // Prints the error with stack trace } ``` @@ -90,19 +89,12 @@ The `xerrors.New` function can wrap existing errors, which is useful for adding **Adding Stack Traces to Existing Errors:** -If you receive an error (like a sentinel error) that lacks a stack trace, you can wrap it using `xerrors.New`: +If you receive an error that lacks a stack trace, you can wrap it using `xerrors.New`: ```go -var ErrResourceBusy = xerrors.Message("resource is busy") - -// ... -originalErr := checkResourceStatus() // Returns ErrResourceBusy without stack trace -if originalErr != nil { - // Wrap the original error to capture the stack trace at this point - errWithTrace := xerrors.New(originalErr) - if errors.Is(errWithTrace, ErrResourceBusy) { - xerrors.Print(errWithTrace) // Prints "resource is busy" with stack trace - } +output, err := json.Marshal(data) +if err != nil { + return xerrors.New("failed to marshal data", err) } ``` @@ -112,7 +104,7 @@ Provide a descriptive string as the first argument to `xerrors.New`, followed by ```go if err := updateUserProfile(user); err != nil { - return xerrors.New("failed to update user profile", err) // failed to update user profile: + return xerrors.New("failed to update user profile", err) } ``` @@ -124,10 +116,10 @@ if err := updateUserProfile(user); err != nil { var ErrConnectionFailed = xerrors.Message("connection failed") var ErrTimeout = xerrors.Message("operation timed out") -// Wrap both errors under a single contextual message -combinedErr := xerrors.New("data retrieval failed", ErrConnectionFailed, ErrTimeout) +// Wrap both errors under a single error +combinedErr := xerrors.New(ErrConnectionFailed, ErrTimeout) -fmt.Println(combinedErr.Error()) // data retrieval failed: connection failed: operation timed out +fmt.Println(combinedErr.Error()) // connection failed: operation timed out ``` This feature is useful when a high-level operation fails due to multiple underlying issues that need to be reported together. diff --git a/doc.go b/doc.go index 3595f6b..df4d3c5 100644 --- a/doc.go +++ b/doc.go @@ -1,7 +1,7 @@ -// Package xerrors is an idiomatic and lightweight package that -// provides a set of functions to make working with errors easier. -// It adds support for stack traces, [multierror]s, and simplifies -// working with wrapped errors and panics. The `go-xerrors` package -// is fully compatible with Go errors 1.13, supporting the -// `errors.As`, `errors.Is`, and `errors.Unwrap` functions. +// Package xerrors is an idiomatic and lightweight Go package designed to +// enhance error handling in Go applications. It provides functions and types +// that simplify common error handling tasks by adding support for stack +// traces, combining multiple errors, and simplifying working with panics. +// The package maintains full compatibility with Go's standard error handling +// features (Go 1.13+), including errors.As, errors.Is, and errors.Unwrap. package xerrors diff --git a/stacktrace.go b/stacktrace.go index 74a9a72..a722888 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -57,7 +57,7 @@ func (e *withStackTrace) ErrorDetails() string { return e.stack.String() } -// Unwrap implements the Go 1.13 `Unwrap() []error` method, returning +// Unwrap implements the Go 1.13 `Unwrap() error` method, returning // the wrapped error. func (e *withStackTrace) Unwrap() error { return e.err diff --git a/wrapper.go b/wrapper.go index 7d5f836..10ba9d3 100644 --- a/wrapper.go +++ b/wrapper.go @@ -42,7 +42,7 @@ func (e *withWrapper) Error() string { return s.String() } -// Unwrap implements the Go 1.13 `Unwrap() []error` method, returning +// Unwrap implements the Go 1.13 `Unwrap() error` method, returning // the wrapped error. func (e *withWrapper) Unwrap() error { return e.err From d7d2b24bd7b0ccbb4dbffd35b1951f5f2718f99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 00:27:12 +0200 Subject: [PATCH 06/19] Add Newf, Joinf, Messagef, remove WithWrapper, update docs --- README.md | 238 +++++++++++++++++++++++++++++++-------------- doc.go | 3 +- format.go | 39 ++++---- format_test.go | 9 +- multierror.go | 18 +++- multierror_test.go | 12 +-- stacktrace.go | 30 +++--- wrapper.go | 43 ++++---- wrapper_test.go | 61 ++++++------ xerrors.go | 154 +++++++++++++++++++++++------ xerrors_test.go | 202 +++++++++++++++++++++++++++++++++++--- 11 files changed, 585 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index 03f4ff7..9850bd5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # go-xerrors -`go-xerrors` is an idiomatic and lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying working with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (Go 1.13+), including `errors.As`, `errors.Is`, and `errors.Unwrap`. +`go-xerrors` is an idiomatic, lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying work with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (including changes in Go 1.13 and 1.20), such as `errors.As`, `errors.Is`, and `errors.Unwrap`. **Main Features:** -- **Stack Traces**: Automatically captures and attaches stack traces to errors upon creation, significantly aiding in debugging and pinpointing the origin of issues. -- **Multi-Errors**: Allows for the aggregation of multiple errors into a single error instance, useful for reporting all failures from operations that involve multiple steps or components. -- **Flexible Error Wrapping**: Provides ways to wrap errors with additional context or messages. Supports wrapping multiple underlying errors simultaneously while preserving the ability to inspect each one individually. -- **Simplified Panic Handling**: Offers functions to convert recovered panic values into standard Go errors, complete with stack traces, facilitating more robust error recovery logic. +- **Stack Traces**: Automatically captures and attaches stack traces to errors upon creation, which significantly aids debugging and helps pinpoint the origin of issues. +- **Multi-Errors**: Enables the aggregation of multiple errors into a single error instance, useful for reporting all failures from operations that involve multiple steps or components. +- **Flexible Error Wrapping**: Provides ways to wrap errors with additional context or messages, while preserving the ability to inspect each underlying error individually. +- **Simplified Panic Handling**: Provides functions for converting recovered panic values into standard Go errors with stack traces, facilitating more robust error recovery logic. --- @@ -17,19 +17,25 @@ go get -u github.com/mdobak/go-xerrors ``` -## Usage +## Basic Usage -### Basic Errors and Stack Traces +### Creating Errors with Stack Traces -A primary use of `go-xerrors` is creating errors that automatically include a stack trace via the `xerrors.New` function: +The primary way to create an error in `go-xerrors` is by using the `xerrors.New` or `xerrors.Newf` functions: ```go +// Create a new error with a stack trace err := xerrors.New("something went wrong") + +// Create a formatted error with a stack trace +err := xerrors.Newf("something went wrong: %s", reason) ``` -Invoking the standard `Error()` method on this `err` returns only the message ("something went wrong"), adhering to the Go convention of providing a concise error description. +Calling the standard `Error()` method on `err` returns only the message ("something went wrong"), adhering to the Go convention of providing a concise error description. + +### Displaying Detailed Errors -To display the error with its associated stack trace and other potential details, use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` functions. These functions are designed to format errors created by `go-xerrors`, including their detailed information. +To display the error with the associated stack trace and additional details, use the `xerrors.Print`, `xerrors.Sprint`, or `xerrors.Fprint` functions: ```go xerrors.Print(err) @@ -44,7 +50,9 @@ Error: something went wrong at runtime.goexit (/usr/local/go/src/runtime/asm_amd64.s:1371) ``` -To retrieve only the stack trace information programmatically, use `xerrors.StackTrace`. This function returns an `xerrors.Callers` object, which can be formatted as a string. +### Working with Stack Traces + +To retrieve only the stack trace information programmatically: ```go trace := xerrors.StackTrace(err) @@ -59,82 +67,80 @@ Output: at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) ``` -### Sentinel Errors - -Sentinel errors are predefined error values representing specific, known failure conditions (e.g., `io.EOF`). They are typically declared as package-level variables. Using sentinel errors allows for reliable error checking using `errors.Is`, avoiding direct string comparisons of error messages. - -`go-xerrors` provides `xerrors.Message` to create distinct sentinel error values with consistent messages. +You can also explicitly add a stack trace to an existing error: ```go -var ErrAccessDenied = xerrors.Message("access denied") +err := someFunction() +errWithStack := xerrors.WithStackTrace(err, 0) // 0 skips no frames +``` -// ... +### Wrapping Errors -func performAction() error { - // ... - return ErrAccessDenied -} - -// ... +The `xerrors.New` and `xerrors.Newf` functions can also wrap existing errors while preserving their stack traces: -err := performAction() -if errors.Is(err, ErrAccessDenied) { - log.Println("Operation failed due to access denial.") +```go +output, err := json.Marshal(data) +if err != nil { + return xerrors.New("failed to marshal data", err) } ``` -### Error Wrapping - -The `xerrors.New` function can wrap existing errors, which is useful for adding stack traces or providing additional contextual information. - -**Adding Stack Traces to Existing Errors:** - -If you receive an error that lacks a stack trace, you can wrap it using `xerrors.New`: +With formatted messages: ```go output, err := json.Marshal(data) if err != nil { - return xerrors.New("failed to marshal data", err) + return xerrors.Newf("failed to marshal data %v: %w", data, err) } ``` -**Wrapping Errors with Additional Context:** +### Creating Error Chains Without Stack Traces -Provide a descriptive string as the first argument to `xerrors.New`, followed by the error(s) to wrap: +For situations where you don't need a stack trace (such as creating sentinel errors), use `xerrors.Join` and `xerrors.Joinf`: ```go -if err := updateUserProfile(user); err != nil { - return xerrors.New("failed to update user profile", err) -} +err := xerrors.Join("operation failed", existingError) ``` -**Wrapping Multiple Errors:** +The key difference between `fmt.Errorf` and `xerrors.Newf`/`xerrors.Joinf` is that the latter functions preserve the error chain, whereas `fmt.Errorf` flattens it (i.e., its `Unwrap` method returns all underlying errors at once, instead of just the next one in the chain). + +### Sentinel Errors -`xerrors.New` can wrap multiple errors simultaneously: +Sentinel errors are predefined error values representing specific, known failure conditions. `go-xerrors` provides `xerrors.Message` to create distinct sentinel error values with consistent messages: ```go -var ErrConnectionFailed = xerrors.Message("connection failed") -var ErrTimeout = xerrors.Message("operation timed out") +var ErrAccessDenied = xerrors.Message("access denied") + +// ... -// Wrap both errors under a single error -combinedErr := xerrors.New(ErrConnectionFailed, ErrTimeout) +func performAction() error { + // ... + return ErrAccessDenied +} + +// ... -fmt.Println(combinedErr.Error()) // connection failed: operation timed out +err := performAction() +if errors.Is(err, ErrAccessDenied) { + log.Println("Operation failed due to access denial.") +} ``` -This feature is useful when a high-level operation fails due to multiple underlying issues that need to be reported together. +For formatted sentinel errors: -### Multi-Errors (Error Aggregation) +```go +const MaxLength = 10 +var ErrInvalidInput = xerrors.Messagef("max length of %d exceeded", MaxLength) +``` -When performing multiple independent operations where several might fail (e.g., validating multiple inputs, processing batch items), use `xerrors.Append` to collect these individual errors into a single multi-error instance. +### Multi-Errors -`xerrors.Append` behaves like Go's built-in `append` but is specifically designed for aggregating errors. +When performing multiple independent operations where several might fail, use `xerrors.Append` to collect these individual errors into a single multi-error instance: ```go var err error if input.Username == "" { - // Append creates/adds to the multi-error, including stack trace if using xerrors.New err = xerrors.Append(err, xerrors.New("username cannot be empty")) } if len(input.Password) < 8 { @@ -161,24 +167,21 @@ The resulting multi-error implements the standard `error` interface as well as ` **Comparison with Go 1.20 `errors.Join`:** -Go 1.20 introduced `errors.Join` for error aggregation. While serving a similar purpose, `xerrors.Append` (especially when used with errors created by `xerrors.New`) offers some differences: +Go 1.20 introduced `errors.Join` for error aggregation. While serving a similar purpose, `xerrors.Append` offers: -1. **Individual Stack Traces**: `go-xerrors` multi-errors preserve the individual stack traces associated with each appended error. `errors.Join` does not inherently manage stack traces for the errors it combines. -2. **Enhanced Formatting**: `xerrors.Print`, `Sprint`, and `Fprint` provide detailed, structured output for multi-errors, listing each constituent error and its stack trace, which can be beneficial for debugging. -3. **Consistent `Error()` Output**: The `Error()` method of a `go-xerrors` multi-error consistently produces a concise, single-line summary. The output format of `errors.Join`'s `Error()` method can vary. +1. **Individual Stack Traces**: Preserves the individual stack traces associated with each appended error +2. **Enhanced Formatting**: Provides detailed, structured output for multi-errors +3. **Consistent `Error()` Output**: Produces a concise, single-line summary ### Simplified Panic Handling -Go's `recover()` built-in returns the value passed to `panic`, which has type `any` and doesn't directly implement the `error` interface. `go-xerrors` provides utilities to convert these recovered values into proper errors with stack traces. +`go-xerrors` provides utilities to convert panic values into proper errors with stack traces. **Using `xerrors.Recover`:** -This function wraps `recover()` and executes a callback function only if a panic occurred. The callback receives the panic value converted to an `error` (with stack trace). Use it directly with `defer`. - ```go func handleTask() (err error) { defer xerrors.Recover(func(err error) { - err = xerrors.FromRecover(r) // Convert recovered value to error with stack trace log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) }) @@ -191,13 +194,11 @@ func handleTask() (err error) { **Using `xerrors.FromRecover`:** -If you prefer the standard `recover()` pattern, use `xerrors.FromRecover` to manually convert the recovered value after checking if `recover()` returned non-nil. - ```go func handleTask() (err error) { defer func() { if r := recover(); r != nil { - err := xerrors.FromRecover(r) // Convert recovered value to error with stack trace + err = xerrors.FromRecover(r) // Convert recovered value to error with stack trace log.Printf("Recovered from panic during task handling: %s", xerrors.Sprint(err)) } }() @@ -209,18 +210,109 @@ func handleTask() (err error) { } ``` -## API Summary +The returned error implements the `PanicError` interface, which provides access to the original panic value via the `Panic()` method. + +## When to use `New`, `Join`, or `Append` + +While all three functions can be used to aggregate errors, they serve different purposes: + +- **`xerrors.New`**: Create errors with stack traces, useful for wrapping existing errors to add context +- **`xerrors.Join`**: Create chained errors without stack traces, useful for defining sentinel errors +- **`xerrors.Append`**: Create multi-errors by aggregating independent errors + +## Examples + +### Error with Stack Trace + +```go +func (m *MyStruct) MarshalJSON() ([]byte, error) { + output, err := json.Marshal(m) + if err != nil { + // Wrap the error with additional context and capture a stack trace. + return nil, xerrors.New("failed to marshal data", err) + } + return output, nil +} +``` + +### Sentinel Errors + +```go +var ( + // Using xerrors.Join lets us create sentinel errors that can be + // checked with errors.Is against both ErrValidation and the + // specific validation error. We do not want to capture a stack trace + // here; hence, we use xerrors.Join instead of xerrors.New. + ErrValidation = xerrors.Message("validation error") + ErrInvalidName = xerrors.Join(ErrValidation, "name is invalid") + ErrInvalidAge = xerrors.Join(ErrValidation, "age is invalid") + ErrInvalidEmail = xerrors.Join(ErrValidation, "email is invalid") +) + +func (m *MyStruct) Validate() error { + if !m.isNameValid() { + return xerrors.New(ErrInvalidName) + } + if !m.isAgeValid() { + return xerrors.New(ErrInvalidAge) + } + if !m.isEmailValid() { + return xerrors.New(ErrInvalidEmail) + } + return nil +} +``` + +### Multi-Error Validation + +```go +func (m *MyStruct) Validate() error { + var err error + if m.Name == "" { + err = xerrors.Append(err, xerrors.New("name cannot be empty")) + } + if m.Age < 0 { + err = xerrors.Append(err, xerrors.New("age cannot be negative")) + } + if m.Email == "" { + err = xerrors.Append(err, xerrors.New("email cannot be empty")) + } + return err +} +``` + +## API Reference + +### Core Functions + +- `xerrors.New(errors ...any) error`: Creates a new error with a stack trace +- `xerrors.Newf(format string, args ...any) error`: Creates a formatted error with a stack trace +- `xerrors.Join(errors ...any) error`: Creates a chained error without a stack trace +- `xerrors.Joinf(format string, args ...any) error`: Creates a formatted chained error without a stack trace +- `xerrors.Message(message string) error`: Creates a simple sentinel error +- `xerrors.Messagef(format string, args ...any) error`: Creates a simple formatted sentinel error +- `xerrors.WithStackTrace(err error, skip int) error`: Wraps an error with a stack trace + +### Multi-Error Functions + +- `xerrors.Append(err error, errs ...error) error`: Aggregates errors into a multi-error + +### Panic Handling + +- `xerrors.Recover(callback func(err error))`: Recovers from panics and invokes a callback with the error +- `xerrors.FromRecover(recoveredValue any) error`: Converts a recovered value to an error with a stack trace + +### Formatting and Stack Trace Functions + +- `xerrors.Print(err error)`: Prints a formatted error to stderr +- `xerrors.Sprint(err error) string`: Returns a formatted error as a string +- `xerrors.Fprint(w io.Writer, err error)`: Writes a formatted error to the provided writer +- `xerrors.StackTrace(err error) Callers`: Extracts the stack trace from an error -Core components of the `go-xerrors` package: +### Key Interfaces -- `xerrors.New(errors ...any) error`: Creates a new error, automatically capturing a stack trace. Can wrap one or multiple existing errors and optionally add a context message. -- `xerrors.Message(message string) error`: Creates a simple sentinel error value identified by its type and message. -- `xerrors.Append(err error, errs ...error) error`: Aggregates errors. Appends new errors to an existing error (or `nil`), returning a multi-error instance. Preserves stack traces of appended errors. -- `xerrors.Recover(callback func(err error))`: A utility function for use with `defer`. It recovers from panics and invokes the provided callback with the panic value converted to an error (including stack trace). -- `xerrors.FromRecover(recoveredValue any) error`: Converts a value returned by the built-in `recover()` function into an error instance with a stack trace. -- `xerrors.Print(err error)`, `xerrors.Sprint(err error) string`, `xerrors.Fprint(w io.Writer, err error)`: Formatting functions that output errors with detailed information, including stack traces and structured multi-error breakdowns. -- `xerrors.StackTrace(err error) Callers`: Extracts the stack trace information from an error created or wrapped by `go-xerrors`. -- Standard Go compatibility: Fully supports `errors.Is`, `errors.As`, and `errors.Unwrap` for interoperability. +- `DetailedError`: For errors that provide detailed information +- `PanicError`: For errors created from panic values with access to the original panic value ## Documentation diff --git a/doc.go b/doc.go index df4d3c5..02a1843 100644 --- a/doc.go +++ b/doc.go @@ -3,5 +3,6 @@ // that simplify common error handling tasks by adding support for stack // traces, combining multiple errors, and simplifying working with panics. // The package maintains full compatibility with Go's standard error handling -// features (Go 1.13+), including errors.As, errors.Is, and errors.Unwrap. +// features (including changes in Go 1.13 and 1.20), such as errors.As, +// errors.Is, and errors.Unwrap. package xerrors diff --git a/format.go b/format.go index 0360607..cc69018 100644 --- a/format.go +++ b/format.go @@ -13,8 +13,8 @@ var errWriter io.Writer = os.Stderr // Print writes a formatted error to stderr. // -// If the error implements the [ErrorDetails] interface, the result -// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// If the error implements the [DetailedError] interface, the result +// of [DetailedError] is used for each wrapped error. Otherwise, the // standard Error method is used. The formatted error can span // multiple lines and always ends with a newline. func Print(err error) { @@ -23,8 +23,8 @@ func Print(err error) { // Sprint returns a formatted error as a string. // -// If the error implements the [ErrorDetails] interface, the result -// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// If the error implements the [DetailedError] interface, the result +// of [DetailedError] is used for each wrapped error. Otherwise, the // standard Error method is used. The formatted error can span // multiple lines and always ends with a newline. func Sprint(err error) string { @@ -35,8 +35,8 @@ func Sprint(err error) string { // Fprint writes a formatted error to the provided [io.Writer]. // -// If the error implements the [ErrorDetails] interface, the result -// of [ErrorDetails] is used for each wrapped error. Otherwise, the +// If the error implements the [DetailedError] interface, the result +// of [DetailedError] is used for each wrapped error. Otherwise, the // standard Error method is used. The formatted error can span // multiple lines and always ends with a newline. func Fprint(w io.Writer, err error) (int, error) { @@ -45,36 +45,39 @@ func Fprint(w io.Writer, err error) (int, error) { // fprint is a helper function that writes the formatted error to the // given [io.Writer]. -func fprint(w io.Writer, e error) (n int, err error) { +// +// This function will print all errors in the chain, that implement the +// [DetailedError] interface, with the exception of the first error, which +// will be printed using the standard Error method if it doesn't implement +// the [DetailedError] interface. +func fprint(w io.Writer, err error) (int, error) { const firstErrorPrefix = "Error: " const previousErrorPrefix = "Previous error: " var buffer bytes.Buffer first := true - for e != nil { - switch terr := e.(type) { - case ErrorDetails: + for err != nil { + switch tErr := err.(type) { + case DetailedError: if first { buffer.WriteString(firstErrorPrefix) } else { buffer.WriteString(previousErrorPrefix) } - buffer.WriteString(terr.Error()) - buffer.WriteByte('\n') - buffer.WriteString(terr.ErrorDetails()) + buffer.WriteString(tErr.DetailedError()) default: - // If an error does not implement the ErrorDetails interface, - // then the Error() method will print all errors separated + // If an error does not implement the DetailedError interface, + // then the Error() method should print all errors separated // with ":", so there is no need to render each error other than // the first one. if first { buffer.WriteString(firstErrorPrefix) - buffer.WriteString(terr.Error()) + buffer.WriteString(tErr.Error()) buffer.WriteByte('\n') } } first = false - if we, ok := e.(unwrapper); ok { - e = we.Unwrap() + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() continue } break diff --git a/format_test.go b/format_test.go index fa8f1d0..3face9b 100644 --- a/format_test.go +++ b/format_test.go @@ -16,8 +16,8 @@ func (e testErr) Error() string { return e.err } -func (e testErr) ErrorDetails() string { - return e.details + "\n" +func (e testErr) DetailedError() string { + return e.err + "\n" + e.details + "\n" } func (e testErr) Unwrap() error { @@ -32,9 +32,6 @@ func TestFormat(t *testing.T) { { err: Message("foo"), want: "Error: foo\n", }, - { - err: WithWrapper(Message("foo"), Message("bar")), want: "Error: foo: bar\n", - }, { err: testErr{err: "err", details: "details"}, want: "Error: err\ndetails\n", @@ -73,7 +70,7 @@ func TestPrint(t *testing.T) { } func TestSprint(t *testing.T) { - a := New("access denided") + a := New("access denied") Print(a) err := Message("foo") diff --git a/multierror.go b/multierror.go index f5e51bf..ac06d34 100644 --- a/multierror.go +++ b/multierror.go @@ -6,7 +6,7 @@ import ( "strings" ) -const multiErrorErrorPrefix = "the following errors occurred: " +const multiErrorPrefix = "the following errors occurred:" // Append appends the provided errors to an existing error or list of // errors. If `err` is not a [multiError], it will be converted into @@ -19,6 +19,9 @@ const multiErrorErrorPrefix = "the following errors occurred: " // The returned error is compatible with Go errors, supporting // [errors.Is], [errors.As], and the Go 1.20 `Unwrap() []error` // method. +// +// To create a chained error, use [New], [Newf], [Join], or +// [Joinf] instead. func Append(err error, errs ...error) error { var me multiError if err != nil { @@ -54,8 +57,8 @@ type multiError []error // Error implements the [error] interface. func (e multiError) Error() string { var s strings.Builder - s.WriteString(multiErrorErrorPrefix) - s.WriteString("[") + s.WriteString(multiErrorPrefix) + s.WriteString(" [") for n, err := range e { s.WriteString(err.Error()) if n < len(e)-1 { @@ -66,9 +69,14 @@ func (e multiError) Error() string { return s.String() } -// ErrorDetails implements the [ErrorDetails] interface. -func (e multiError) ErrorDetails() string { +// DetailedError implements the [DetailedError] interface. +func (e multiError) DetailedError() string { + if len(e) == 0 { + return "" + } var s strings.Builder + s.WriteString(multiErrorPrefix) + s.WriteByte('\n') for n, err := range e.Unwrap() { s.WriteString(strconv.Itoa(n + 1)) s.WriteString(". ") diff --git a/multierror_test.go b/multierror_test.go index eef26dd..ee51df8 100644 --- a/multierror_test.go +++ b/multierror_test.go @@ -60,22 +60,22 @@ func TestAppend(t *testing.T) { } } -func TestMultiError_ErrorDetails(t *testing.T) { +func TestMultiError_DetailedError(t *testing.T) { tests := []struct { errs []error want string regexp bool }{ {errs: []error{}, want: ``}, - {errs: []error{Message("a")}, want: "1. Error: a\n"}, - {errs: []error{Message("a"), Message("b")}, want: "1. Error: a\n2. Error: b\n"}, - {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: the following errors occurred: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, + {errs: []error{Message("a")}, want: "the following errors occurred:\n1. Error: a\n"}, + {errs: []error{Message("a"), Message("b")}, want: "the following errors occurred:\n1. Error: a\n2. Error: b\n"}, + {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "the following errors occurred:\n1. Error: a\n2. Error: the following errors occurred:\n\t1. Error: b\n\t2. Error: c\n"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { err := multiError(tt.errs) - if got := err.ErrorDetails(); got != tt.want { - t.Errorf("multiError(errs).ErrorDetails(): %q does not match %q", got, tt.want) + if got := err.DetailedError(); got != tt.want { + t.Errorf("multiError(errs).DetailedError(): %q does not match %q", got, tt.want) } }) } diff --git a/stacktrace.go b/stacktrace.go index a722888..832d960 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -10,20 +10,22 @@ import ( const stackTraceDepth = 32 -// StackTrace extracts the stack trace from the given error or the -// first wrapped error that implements [stackTracer]. +// StackTrace extracts the stack trace from the provided error. +// It traverses the error chain, looking for the last error that +// has a stack trace. func StackTrace(err error) Callers { + var callers Callers for err != nil { - if e, ok := err.(stackTracer); ok { - return e.StackTrace() + if e, ok := err.(interface{ StackTrace() Callers }); ok { + callers = e.StackTrace() } - if e, ok := err.(unwrapper); ok { + if e, ok := err.(interface{ Unwrap() error }); ok { err = e.Unwrap() continue } break } - return nil + return callers } // WithStackTrace wraps the provided error with a stack trace, @@ -52,9 +54,13 @@ func (e *withStackTrace) Error() string { return e.err.Error() } -// ErrorDetails implements the [ErrorDetails] interface. -func (e *withStackTrace) ErrorDetails() string { - return e.stack.String() +// DetailedError implements the [DetailedError] interface. +func (e *withStackTrace) DetailedError() string { + s := &strings.Builder{} + s.WriteString(e.err.Error()) + s.WriteString("\n") + s.WriteString(e.stack.String()) + return s.String() } // Unwrap implements the Go 1.13 `Unwrap() error` method, returning @@ -200,12 +206,6 @@ func (c Callers) writeTrace(w io.Writer) { } } -// stackTracer represents an error that provides a stack trace. -type stackTracer interface { - error - StackTrace() Callers -} - // callers captures the current stack trace, skipping the specified // number of frames. func callers(skip int) Callers { diff --git a/wrapper.go b/wrapper.go index 10ba9d3..75d8742 100644 --- a/wrapper.go +++ b/wrapper.go @@ -5,45 +5,36 @@ import ( "strings" ) -// WithWrapper wraps `err` with a `wrapper` error. -// -// The `wrapper` should generally be a simple, sentinel error, as -// details like its stack trace are ignored. The `Unwrap` method -// will only unwrap `err`, but [errors.Is] and [errors.As] work -// with both `wrapper` and `err`. -// -// If `wrapper` is nil, `err` is returned. If `err` is nil, -// WithWrapper returns nil. -func WithWrapper(wrapper error, err error) error { - if err == nil { - return nil - } - if wrapper == nil { - return err - } - return &withWrapper{ - wrapper: wrapper, - err: err, - } -} - // withWrapper wraps an error with another error. +// +// It is intended to be build error chains, e.g. if we have a +// following error chain: `err1: err2: err3`, the wrapper is `err1`, +// and the err is another withWrapper containing `err2` and `err3`. type withWrapper struct { - wrapper error - err error + wrapper error // wrapper is the error that wraps the next error in the chain, may be nil + err error // err is the next error in the chain, must not be nil + msg string // msg overwrites the error message, if set } // Error implements the [error] interface. func (e *withWrapper) Error() string { + if e.msg != "" { + return e.msg + } s := &strings.Builder{} - s.WriteString(e.wrapper.Error()) - s.WriteString(": ") + if e.wrapper != nil { + s.WriteString(e.wrapper.Error()) + s.WriteString(": ") + } s.WriteString(e.err.Error()) return s.String() } // Unwrap implements the Go 1.13 `Unwrap() error` method, returning // the wrapped error. +// +// Since withWrapper represents a chain of errors, the Unwrap method +// returns the next error in the chain, not both the wrapper and the error. func (e *withWrapper) Unwrap() error { return e.err } diff --git a/wrapper_test.go b/wrapper_test.go index 191dd9d..7caf6ab 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -8,45 +8,42 @@ import ( "testing" ) -func TestWrap(t *testing.T) { +func TestWithWrapper(t *testing.T) { tests := []struct { - err error wrapper error + err error + msg string want string - wantNil bool }{ - {err: Message("err"), wrapper: Message("wrapper"), want: "wrapper: err"}, - {err: io.EOF, wrapper: Message("wrapper"), want: "wrapper: EOF"}, - {err: nil, wrapper: Message("wrapper"), wantNil: true}, - {err: Message("err"), wrapper: nil, want: "err"}, + {wrapper: Message("wrapper"), err: Message("err"), want: "wrapper: err"}, + {wrapper: Message("wrapper"), err: io.EOF, want: "wrapper: EOF"}, + {wrapper: nil, err: Message("err"), want: "err"}, + {wrapper: Message("wrapper"), err: Message("err"), msg: "msg", want: "msg"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { - got := WithWrapper(tt.wrapper, tt.err) - switch { - case tt.wantNil: - if got != nil { - t.Errorf("WithWrapper(%#v, %#v): expected nil", tt.wrapper, tt.err) - } - default: - if got.Error() != tt.want { - t.Errorf("WithWrapper(%#v, %#v): got: %q, want %q", tt.wrapper, tt.err, got, tt.want) - } - if len(StackTrace(got)) != 0 { - t.Errorf("WithWrapper(%#v, %#v): returned error must not contain a stack trace", tt.wrapper, tt.err) - } - if !errors.Is(got, tt.err) { - t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for err", tt.wrapper, tt.err) - } - if tt.wrapper != nil && !errors.Is(got, tt.wrapper) { - t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for wrapper", tt.wrapper, tt.err) - } - if tt.err != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.err)).Interface()) { - t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the err error type", tt.wrapper, tt.err) - } - if tt.wrapper != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.wrapper)).Interface()) { - t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the wrapper error type", tt.wrapper, tt.err) - } + got := &withWrapper{ + wrapper: tt.wrapper, + err: tt.err, + msg: tt.msg, + } + if got.Error() != tt.want { + t.Errorf("WithWrapper(%#v, %#v): got: %q, want %q", tt.wrapper, tt.err, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("WithWrapper(%#v, %#v): returned error must not contain a stack trace", tt.wrapper, tt.err) + } + if !errors.Is(got, tt.err) { + t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for err", tt.wrapper, tt.err) + } + if tt.wrapper != nil && !errors.Is(got, tt.wrapper) { + t.Errorf("WithWrapper(%#v, %#v): errors.Is must return true for wrapper", tt.wrapper, tt.err) + } + if tt.err != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.err)).Interface()) { + t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the err error type", tt.wrapper, tt.err) + } + if tt.wrapper != nil && !errors.As(got, reflect.New(reflect.TypeOf(tt.wrapper)).Interface()) { + t.Errorf("errors.As(WithWrapper(%#v, %#v), err): must return true for the wrapper error type", tt.wrapper, tt.err) } }) } diff --git a/xerrors.go b/xerrors.go index c4ddf69..6b14618 100644 --- a/xerrors.go +++ b/xerrors.go @@ -4,14 +4,14 @@ import ( "fmt" ) -// ErrorDetails represents an error that provides additional details +// DetailedError represents an error that provides additional details // beyond the error message. // -// The ErrorDetails method returns a longer, multi-line description +// The DetailedError method returns a longer, multi-line description // of the error. It always ends with a new line. -type ErrorDetails interface { +type DetailedError interface { error - ErrorDetails() string + DetailedError() string } // Message creates a simple error with the given message, without @@ -20,10 +20,27 @@ type ErrorDetails interface { // // This function is useful for creating sentinel errors, often // referred to as "constant errors." +// +// To create an error with a stack trace, use [New] or [Newf] +// instead. func Message(msg string) error { return &messageError{msg: msg} } +// Messagef creates a simple error with a formatted message, +// without recording a stack trace. The format string follows the +// conventions of [fmt.Sprintf]. Each call returns a distinct error +// instance, even if the message is identical. +// +// This function is useful for creating sentinel errors, often +// referred to as "constant errors." +// +// To create an error with a stack trace, use [New] or [Newf] +// instead. +func Messagef(format string, args ...any) error { + return &messageError{msg: fmt.Sprintf(format, args...)} +} + // New creates a new error from the provided values and records a // stack trace at the point of the call. If multiple values are // provided, each value is wrapped by the previous one, forming a @@ -47,38 +64,119 @@ func Message(msg string) error { // // If called with no arguments or only nil values, New returns nil. // -// For simple errors without a stack trace, use the [Message] -// function. +// To create a sentinel error, use [Message] or [Messagef] instead. func New(vals ...any) error { - var errs error - for _, val := range vals { - if val == nil { - continue - } - err := toError(val) - if errs == nil { - errs = err - } else { - errs = &withWrapper{ - wrapper: errs, - err: err, - } - } - } - if errs == nil { + err := Join(vals...) + if err == nil { return nil } return &withStackTrace{ - err: errs, + err: err, stack: callers(1), } } -// unwrapper represents an error that wraps another error, providing -// additional context. -type unwrapper interface { - error - Unwrap() error +// Newf creates a new error with a formatted message and records a +// stack trace at the point of the call. The format string follows +// the conventions of [fmt.Errorf]. +// +// Unlike errors created by [fmt.Errorf], the Unwrap method on the +// returned error yields the next wrapped error, not a slice of errors, +// since this function is intended for creating linear error chains. +// +// To create a sentinel error, use [Message] or [Messagef] instead. +func Newf(format string, args ...any) error { + return &withStackTrace{ + err: Joinf(format, args...), + stack: callers(1), + } +} + +// Join joins multiple values into a single error, forming a chain +// of errors. +// +// Conversion rules for arguments: +// - If the value is an error, it is used as is. +// - If the value is a string, a new error with that message is +// created. +// - If the value implements [fmt.Stringer], the result of +// String() is used to create an error. +// - If the value is nil, it is ignored. +// - Otherwise, the result of [fmt.Sprint] is used to create an +// error. +// +// If called with no arguments or only nil values, Join returns nil. +// +// To create a multi-error instead of an error chain, use [Append]. +func Join(vals ...any) error { + var wErr error + for i := len(vals) - 1; i >= 0; i-- { + if vals[i] == nil { + continue + } + err := toError(vals[i]) + if wErr == nil { + wErr = err + continue + } + wErr = &withWrapper{ + wrapper: err, + err: wErr, + } + } + return wErr +} + +// Joinf joins multiple values into a single error with a formatted +// message, forming an error chain. The format string follows the +// conventions of [fmt.Errorf]. +// +// Unlike errors created by [fmt.Errorf], the Unwrap method on the +// returned error yields the next wrapped error, not a slice of errors, +// since this function is intended for creating linear error chains. +// +// To create a multi-error instead of an error chain, use [Append]. +func Joinf(format string, args ...any) error { + err := fmt.Errorf(format, args...) + switch u := err.(type) { + case interface { + Unwrap() error + }: + return &withWrapper{ + err: u.Unwrap(), + msg: err.Error(), + } + case interface { + Unwrap() []error + }: + var wErr error + errs := u.Unwrap() + for i := len(errs) - 1; i >= 0; i-- { + if errs[i] == nil { + continue + } + if wErr == nil { + wErr = errs[i] + continue + } + wErr = &withWrapper{ + wrapper: errs[i], + err: wErr, + } + } + // Because the formatted message may not follow the "err1: err2: err3" + // pattern, we set the msg field to overwrite the wrapper's message. + if wErr, ok := wErr.(*withWrapper); ok { + wErr.msg = err.Error() + return wErr + } + return &withWrapper{ + err: wErr, + msg: err.Error(), + } + default: + return &messageError{msg: err.Error()} + } } // messageError represents a simple error that contains only a string diff --git a/xerrors_test.go b/xerrors_test.go index be1ae95..0071bec 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -3,7 +3,7 @@ package xerrors import ( "errors" "fmt" - "io" + "strings" "testing" ) @@ -23,54 +23,157 @@ func TestMessage(t *testing.T) { } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { - err := Message(tt.val) - if got := err.Error(); got != tt.want { - t.Errorf("Message(%#v): got: %q, want %q", tt.val, got, tt.want) + got1 := Message(tt.val) + got2 := Message(tt.val) + if msg := got1.Error(); msg != tt.want { + t.Errorf("Message(%#v): got: %q, want %q", tt.val, msg, tt.want) } - if len(StackTrace(err)) != 0 { + if len(StackTrace(got1)) != 0 { t.Errorf("Message(%#v): returned error must not contain a stack trace", tt.val) } + if got1 == got2 { + t.Errorf("Message(%#v): returned error must not be the same instance", tt.val) + } + }) + } +} + +func TestMessagef(t *testing.T) { + tests := []struct { + format string + args []any + want string + }{ + {format: "", args: nil, want: ""}, + {format: "foo", args: nil, want: "foo"}, + {format: "foo %d", args: []any{42}, want: "foo 42"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got1 := Messagef(tt.format, tt.args...) + got2 := Messagef(tt.format, tt.args...) + if msg := got1.Error(); msg != tt.want { + t.Errorf("Messagef(%q, %#v): got: %q, want %q", tt.format, tt.args, msg, tt.want) + } + if len(StackTrace(got1)) != 0 { + t.Errorf("Messagef(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + if got1 == got2 { + t.Errorf("Messagef(%q, %#v): returned error must not be the same instance", tt.format, tt.args) + } }) } } func TestNew(t *testing.T) { + // Since New is mostly a wrapper around Join, we only test + // the error message and stack trace. + tests := []struct { + vals []any + want string + wantNil bool + }{ + {vals: []any{"foo", "bar"}, want: "foo: bar"}, + {vals: []any{nil}, wantNil: true}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := New(tt.vals...) + switch { + case tt.wantNil: + if got != nil { + t.Errorf("New(%#v): expected nil", tt.vals) + } + default: + if got.Error() != tt.want { + t.Errorf("New(%#v): got: %q, want %q", tt.vals, got, tt.want) + } + st := StackTrace(got) + if len(st) == 0 { + t.Errorf("New(%#v): returned error must contain a stack trace", tt.vals) + return + } + if !strings.Contains(st.Frames()[0].Function, "TestNew") { + t.Errorf("New(%#v): first frame must point to TestNew", tt.vals) + } + } + }) + } +} + +func TestNewf(t *testing.T) { + // Since Newf is mostly a wrapper around Joinf, we only test + // the error message and stack trace. + tests := []struct { + format string + args []any + want string + }{ + {format: "foo", args: nil, want: "foo"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Newf(tt.format, tt.args...) + if got.Error() != tt.want { + t.Errorf("Newf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + st := StackTrace(got) + if len(st) == 0 { + t.Errorf("Newf(%q, %#v): returned error must contain a stack trace", tt.format, tt.args) + return + } + if !strings.Contains(st.Frames()[0].Function, "TestNewf") { + t.Errorf("Newf(%q, %#v): first frame must point to TestNewf", tt.format, tt.args) + } + }) + } +} + +func TestJoin(t *testing.T) { tests := []struct { vals []any want string wantNil bool }{ + // String {vals: []any{""}, want: ""}, {vals: []any{"foo", "bar"}, want: "foo: bar"}, - {vals: []any{nil, "foo", "bar"}, want: "foo: bar"}, - {vals: []any{"foo", nil, "bar"}, want: "foo: bar"}, + + // Error {vals: []any{Message("foo"), Message("bar")}, want: "foo: bar"}, - {vals: []any{io.EOF, io.EOF}, want: "EOF: EOF"}, + + // Stringer {vals: []any{stringer{s: "foo"}, stringer{s: "bar"}}, want: "foo: bar"}, + + // Sprintf {vals: []any{42, 314}, want: "42: 314"}, + + // Nil cases {vals: []any{}, wantNil: true}, {vals: []any{nil}, wantNil: true}, {vals: []any{nil, nil}, wantNil: true}, + {vals: []any{nil, "foo", "bar"}, want: "foo: bar"}, + {vals: []any{"foo", nil, "bar"}, want: "foo: bar"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { - got := New(tt.vals...) + got := Join(tt.vals...) switch { case tt.wantNil: if got != nil { - t.Errorf("New(%#v): expected nil", tt.vals) + t.Errorf("Join(%#v): expected nil", tt.vals) } default: if got.Error() != tt.want { - t.Errorf("New(%#v): got: %q, want %q", tt.vals, got, tt.want) + t.Errorf("Join(%#v): got: %q, want %q", tt.vals, got, tt.want) } - if len(StackTrace(got)) == 0 { - t.Errorf("New(%#v): returned error must contain a stack trace", tt.vals) + if len(StackTrace(got)) != 0 { + t.Errorf("Join(%#v): returned error must not contain a stack trace", tt.vals) } for _, v := range tt.vals { if err, ok := v.(error); ok { if !errors.Is(got, err) { - t.Errorf("errors.Is(New(errs...), err): must return true") + t.Errorf("errors.Is(Join(errs...), err): must return true") } } } @@ -78,3 +181,74 @@ func TestNew(t *testing.T) { }) } } + +func TestJoin_Unwrap(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + got := Join(err1, err2) + unwrapper, ok := got.(interface{ Unwrap() error }) + if !ok { + t.Fatalf("Join(err1, err2) must implement Unwrap()") + } + unwrapped := unwrapper.Unwrap() + if unwrapped == nil { + t.Fatalf("Join(err1, err2).Unwrap() must not return nil") + } + if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { + t.Fatalf("Join(err1, err2).Unwrap() must return the second error") + } +} + +func TestJoinf(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + tests := []struct { + format string + args []any + want string + }{ + {format: "simple error", args: nil, want: "simple error"}, + {format: "error with value %d", args: []any{42}, want: "error with value 42"}, + {format: "wrapped error: %w", args: []any{err1}, want: "wrapped error: first error"}, + {format: "multiple errors: %w: %w", args: []any{err1, err2}, want: "multiple errors: first error: second error"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Joinf(tt.format, tt.args...) + if got == nil { + t.Errorf("Joinf(%q, %#v): expected non-nil error", tt.format, tt.args) + return + } + if got.Error() != tt.want { + t.Errorf("Joinf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("Joinf(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + for _, v := range tt.args { + if err, ok := v.(error); ok { + if !errors.Is(got, err) { + t.Errorf("errors.Is(Joinf(errs...), err): must return true") + } + } + } + }) + } +} + +func TestJoinf_Unwrap(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + got := Joinf("%w: %w", err1, err2) + unwrapper, ok := got.(interface{ Unwrap() error }) + if !ok { + t.Fatalf("Join(err1, err2) must implement Unwrap()") + } + unwrapped := unwrapper.Unwrap() + if unwrapped == nil { + t.Fatalf("Join(err1, err2).Unwrap() must not return nil") + } + if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { + t.Fatalf("Join(err1, err2).Unwrap() must return the second error") + } +} From 63f2694be0c4b211951394f5e5f3da48245a0df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 00:35:11 +0200 Subject: [PATCH 07/19] Make pkg compatible with Go <1.20 --- xerrors_go120_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++ xerrors_test.go | 27 +++---------------- 2 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 xerrors_go120_test.go diff --git a/xerrors_go120_test.go b/xerrors_go120_test.go new file mode 100644 index 0000000..bc4cef6 --- /dev/null +++ b/xerrors_go120_test.go @@ -0,0 +1,61 @@ +//go:build go1.20 +// +build go1.20 + +package xerrors + +import ( + "errors" + "fmt" + "testing" +) + +func TestJoinf_Go120(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + tests := []struct { + format string + args []any + want string + }{ + {format: "multiple errors: %w: %w", args: []any{err1, err2}, want: "multiple errors: first error: second error"}, + } + for n, tt := range tests { + t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { + got := Joinf(tt.format, tt.args...) + if got == nil { + t.Errorf("Joinf(%q, %#v): expected non-nil error", tt.format, tt.args) + return + } + if got.Error() != tt.want { + t.Errorf("Joinf(%q, %#v): got: %q, want %q", tt.format, tt.args, got, tt.want) + } + if len(StackTrace(got)) != 0 { + t.Errorf("Joinf(%q, %#v): returned error must not contain a stack trace", tt.format, tt.args) + } + for _, v := range tt.args { + if err, ok := v.(error); ok { + if !errors.Is(got, err) { + t.Errorf("errors.Is(Joinf(errs...), err): must return true") + } + } + } + }) + } +} + +func TestJoinf_Unwrap(t *testing.T) { + err1 := Message("first error") + err2 := Message("second error") + got := Joinf("%w: %w", err1, err2) + unwrapper, ok := got.(interface{ Unwrap() error }) + if !ok { + t.Fatalf("Join(err1, err2) must implement Unwrap()") + } + unwrapped := unwrapper.Unwrap() + if unwrapped == nil { + t.Fatalf("Join(err1, err2).Unwrap() must not return nil") + } + if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { + t.Fatalf("Join(err1, err2).Unwrap() must return the second error") + } +} diff --git a/xerrors_test.go b/xerrors_test.go index 0071bec..7c35b46 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -182,26 +182,8 @@ func TestJoin(t *testing.T) { } } -func TestJoin_Unwrap(t *testing.T) { - err1 := Message("first error") - err2 := Message("second error") - got := Join(err1, err2) - unwrapper, ok := got.(interface{ Unwrap() error }) - if !ok { - t.Fatalf("Join(err1, err2) must implement Unwrap()") - } - unwrapped := unwrapper.Unwrap() - if unwrapped == nil { - t.Fatalf("Join(err1, err2).Unwrap() must not return nil") - } - if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { - t.Fatalf("Join(err1, err2).Unwrap() must return the second error") - } -} - func TestJoinf(t *testing.T) { - err1 := Message("first error") - err2 := Message("second error") + err := Message("first error") tests := []struct { format string args []any @@ -209,8 +191,7 @@ func TestJoinf(t *testing.T) { }{ {format: "simple error", args: nil, want: "simple error"}, {format: "error with value %d", args: []any{42}, want: "error with value 42"}, - {format: "wrapped error: %w", args: []any{err1}, want: "wrapped error: first error"}, - {format: "multiple errors: %w: %w", args: []any{err1, err2}, want: "multiple errors: first error: second error"}, + {format: "wrapped error: %w", args: []any{err}, want: "wrapped error: first error"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { @@ -236,10 +217,10 @@ func TestJoinf(t *testing.T) { } } -func TestJoinf_Unwrap(t *testing.T) { +func TestJoin_Unwrap(t *testing.T) { err1 := Message("first error") err2 := Message("second error") - got := Joinf("%w: %w", err1, err2) + got := Join(err1, err2) unwrapper, ok := got.(interface{ Unwrap() error }) if !ok { t.Fatalf("Join(err1, err2) must implement Unwrap()") From 1e420da3bff8659fb15da59533d8d2c9ebc3d558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 00:37:13 +0200 Subject: [PATCH 08/19] Fix linter warnings --- xerrors_go120_test.go | 2 +- xerrors_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/xerrors_go120_test.go b/xerrors_go120_test.go index bc4cef6..87fd994 100644 --- a/xerrors_go120_test.go +++ b/xerrors_go120_test.go @@ -55,7 +55,7 @@ func TestJoinf_Unwrap(t *testing.T) { if unwrapped == nil { t.Fatalf("Join(err1, err2).Unwrap() must not return nil") } - if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { + if errors.Is(unwrapped, err1) || !errors.Is(unwrapped, err2) { t.Fatalf("Join(err1, err2).Unwrap() must return the second error") } } diff --git a/xerrors_test.go b/xerrors_test.go index 7c35b46..ae8704b 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -229,7 +229,7 @@ func TestJoin_Unwrap(t *testing.T) { if unwrapped == nil { t.Fatalf("Join(err1, err2).Unwrap() must not return nil") } - if !(!errors.Is(unwrapped, err1) && errors.Is(unwrapped, err2)) { + if errors.Is(unwrapped, err1) || !errors.Is(unwrapped, err2) { t.Fatalf("Join(err1, err2).Unwrap() must return the second error") } } From a68c4a3c0ed3b1550a8a92d76be90b876ef4e7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 00:42:07 +0200 Subject: [PATCH 09/19] Rename test to TestJoinf_Unwrap_Go120 --- xerrors_go120_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xerrors_go120_test.go b/xerrors_go120_test.go index 87fd994..3528ca0 100644 --- a/xerrors_go120_test.go +++ b/xerrors_go120_test.go @@ -43,7 +43,7 @@ func TestJoinf_Go120(t *testing.T) { } } -func TestJoinf_Unwrap(t *testing.T) { +func TestJoinf_Unwrap_Go120(t *testing.T) { err1 := Message("first error") err2 := Message("second error") got := Joinf("%w: %w", err1, err2) From 140df6e5360cd7aabde1074e54bd3e4b8e460102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 00:49:50 +0200 Subject: [PATCH 10/19] Add Release job to a GitHub workflow --- .github/workflows/go.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 376ab29..9fc7376 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -2,6 +2,8 @@ name: Go on: push: + branches: [ master ] + tags: [ 'v*' ] pull_request: jobs: @@ -29,3 +31,19 @@ jobs: uses: "golangci/golangci-lint-action@v7" with: version: "v2.0" + + Release: + if: startsWith(github.ref, 'refs/tags/') + needs: CI + runs-on: ubuntu-latest + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false From 36a420d82b0da6ebbe3867b640e7b11b8eff993c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 01:05:18 +0200 Subject: [PATCH 11/19] Update documentation --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9850bd5..2b126c5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ go get -u github.com/mdobak/go-xerrors ``` -## Basic Usage +## Usage ### Creating Errors with Stack Traces @@ -212,7 +212,7 @@ func handleTask() (err error) { The returned error implements the `PanicError` interface, which provides access to the original panic value via the `Panic()` method. -## When to use `New`, `Join`, or `Append` +### When to use `New`, `Join`, or `Append` While all three functions can be used to aggregate errors, they serve different purposes: @@ -220,9 +220,9 @@ While all three functions can be used to aggregate errors, they serve different - **`xerrors.Join`**: Create chained errors without stack traces, useful for defining sentinel errors - **`xerrors.Append`**: Create multi-errors by aggregating independent errors -## Examples +#### Examples -### Error with Stack Trace +##### Error with Stack Trace ```go func (m *MyStruct) MarshalJSON() ([]byte, error) { @@ -235,7 +235,7 @@ func (m *MyStruct) MarshalJSON() ([]byte, error) { } ``` -### Sentinel Errors +##### Sentinel Errors ```go var ( @@ -263,7 +263,7 @@ func (m *MyStruct) Validate() error { } ``` -### Multi-Error Validation +##### Multi-Error Validation ```go func (m *MyStruct) Validate() error { @@ -291,7 +291,6 @@ func (m *MyStruct) Validate() error { - `xerrors.Joinf(format string, args ...any) error`: Creates a formatted chained error without a stack trace - `xerrors.Message(message string) error`: Creates a simple sentinel error - `xerrors.Messagef(format string, args ...any) error`: Creates a simple formatted sentinel error -- `xerrors.WithStackTrace(err error, skip int) error`: Wraps an error with a stack trace ### Multi-Error Functions @@ -302,12 +301,16 @@ func (m *MyStruct) Validate() error { - `xerrors.Recover(callback func(err error))`: Recovers from panics and invokes a callback with the error - `xerrors.FromRecover(recoveredValue any) error`: Converts a recovered value to an error with a stack trace -### Formatting and Stack Trace Functions +### Formatting Functions - `xerrors.Print(err error)`: Prints a formatted error to stderr - `xerrors.Sprint(err error) string`: Returns a formatted error as a string - `xerrors.Fprint(w io.Writer, err error)`: Writes a formatted error to the provided writer + +### Stack Trace Functions + - `xerrors.StackTrace(err error) Callers`: Extracts the stack trace from an error +- `xerrors.WithStackTrace(err error, skip int) error`: Wraps an error with a stack trace ### Key Interfaces From 68aacfa102495ce0d81990f3a83eca641de64c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 01:08:56 +0200 Subject: [PATCH 12/19] Increase stack trace depth --- stacktrace.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacktrace.go b/stacktrace.go index 832d960..c12cc48 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -8,7 +8,7 @@ import ( "strings" ) -const stackTraceDepth = 32 +const stackTraceDepth = 128 // StackTrace extracts the stack trace from the provided error. // It traverses the error chain, looking for the last error that From cb5c37bcdacfaf93dba693f016607f9c04477dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Sun, 27 Apr 2025 01:10:16 +0200 Subject: [PATCH 13/19] Fix multi-error example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b126c5..286edc6 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ if err != nil { // Detailed output using xerrors.Print: xerrors.Print(err) // Output: - // Error: the following errors occurred: [username cannot be empty, password must be at least 8 characters] + // Error: the following errors occurred: // 1. Error: username cannot be empty // at main.validateInput (/path/to/your/file.go:XX) // ... stack trace ... From eff67526ae243a0080c6c13540e0d8164825593d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Mon, 28 Apr 2025 17:13:38 +0200 Subject: [PATCH 14/19] Update DetailedError interface to allow return an empty string --- format.go | 44 ++++++++++++++++++++++++-------------------- format_test.go | 4 ++-- multierror.go | 6 ++---- multierror_test.go | 12 ++++++------ panic.go | 8 ++++++++ stacktrace.go | 10 +++------- wrapper.go | 8 ++++++++ xerrors.go | 11 +++++++---- 8 files changed, 60 insertions(+), 43 deletions(-) diff --git a/format.go b/format.go index cc69018..77f13db 100644 --- a/format.go +++ b/format.go @@ -43,36 +43,40 @@ func Fprint(w io.Writer, err error) (int, error) { return fprint(w, err) } -// fprint is a helper function that writes the formatted error to the +// fprint is a helper function that writes a formatted error to the // given [io.Writer]. // -// This function will print all errors in the chain, that implement the -// [DetailedError] interface, with the exception of the first error, which -// will be printed using the standard Error method if it doesn't implement -// the [DetailedError] interface. +// This function prints all errors in the chain that implement the +// [DetailedError] interface. The first error is printed using the +// standard Error method if it does not implement [DetailedError]. func fprint(w io.Writer, err error) (int, error) { const firstErrorPrefix = "Error: " const previousErrorPrefix = "Previous error: " - var buffer bytes.Buffer + var buf bytes.Buffer first := true for err != nil { - switch tErr := err.(type) { - case DetailedError: + errMsg := err.Error() + errDetails := "" + if dErr, ok := err.(DetailedError); ok { + errDetails = dErr.ErrorDetails() + } + if errDetails != "" { if first { - buffer.WriteString(firstErrorPrefix) + buf.WriteString(firstErrorPrefix) } else { - buffer.WriteString(previousErrorPrefix) + buf.WriteString(previousErrorPrefix) } - buffer.WriteString(tErr.DetailedError()) - default: - // If an error does not implement the DetailedError interface, - // then the Error() method should print all errors separated - // with ":", so there is no need to render each error other than - // the first one. + buf.WriteString(errMsg) + buf.WriteString("\n") + buf.WriteString(errDetails) + } else { + // If an error does not have any details, then the Error() method + // should print all errors separated with ":", so there is no need + // to render each error other than the first one. if first { - buffer.WriteString(firstErrorPrefix) - buffer.WriteString(tErr.Error()) - buffer.WriteByte('\n') + buf.WriteString(firstErrorPrefix) + buf.WriteString(errMsg) + buf.WriteByte('\n') } } first = false @@ -82,7 +86,7 @@ func fprint(w io.Writer, err error) (int, error) { } break } - return w.Write(buffer.Bytes()) + return w.Write(buf.Bytes()) } // format is a helper function to format custom types when diff --git a/format_test.go b/format_test.go index 3face9b..d30906b 100644 --- a/format_test.go +++ b/format_test.go @@ -16,8 +16,8 @@ func (e testErr) Error() string { return e.err } -func (e testErr) DetailedError() string { - return e.err + "\n" + e.details + "\n" +func (e testErr) ErrorDetails() string { + return e.details + "\n" } func (e testErr) Unwrap() error { diff --git a/multierror.go b/multierror.go index ac06d34..b84d629 100644 --- a/multierror.go +++ b/multierror.go @@ -69,14 +69,12 @@ func (e multiError) Error() string { return s.String() } -// DetailedError implements the [DetailedError] interface. -func (e multiError) DetailedError() string { +// ErrorDetails implements the [DetailedError] interface. +func (e multiError) ErrorDetails() string { if len(e) == 0 { return "" } var s strings.Builder - s.WriteString(multiErrorPrefix) - s.WriteByte('\n') for n, err := range e.Unwrap() { s.WriteString(strconv.Itoa(n + 1)) s.WriteString(". ") diff --git a/multierror_test.go b/multierror_test.go index ee51df8..eef26dd 100644 --- a/multierror_test.go +++ b/multierror_test.go @@ -60,22 +60,22 @@ func TestAppend(t *testing.T) { } } -func TestMultiError_DetailedError(t *testing.T) { +func TestMultiError_ErrorDetails(t *testing.T) { tests := []struct { errs []error want string regexp bool }{ {errs: []error{}, want: ``}, - {errs: []error{Message("a")}, want: "the following errors occurred:\n1. Error: a\n"}, - {errs: []error{Message("a"), Message("b")}, want: "the following errors occurred:\n1. Error: a\n2. Error: b\n"}, - {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "the following errors occurred:\n1. Error: a\n2. Error: the following errors occurred:\n\t1. Error: b\n\t2. Error: c\n"}, + {errs: []error{Message("a")}, want: "1. Error: a\n"}, + {errs: []error{Message("a"), Message("b")}, want: "1. Error: a\n2. Error: b\n"}, + {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: the following errors occurred: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { err := multiError(tt.errs) - if got := err.DetailedError(); got != tt.want { - t.Errorf("multiError(errs).DetailedError(): %q does not match %q", got, tt.want) + if got := err.ErrorDetails(); got != tt.want { + t.Errorf("multiError(errs).ErrorDetails(): %q does not match %q", got, tt.want) } }) } diff --git a/panic.go b/panic.go index fd917e1..fc410d6 100644 --- a/panic.go +++ b/panic.go @@ -61,3 +61,11 @@ func (e *panicError) Panic() any { func (e *panicError) Error() string { return fmt.Sprintf("panic: %v", e.panic) } + +// ErrorDetails implements the [DetailedError] interface. +func (e *panicError) ErrorDetails() string { + if dErr, ok := e.panic.(DetailedError); ok { + return dErr.ErrorDetails() + } + return "" +} diff --git a/stacktrace.go b/stacktrace.go index c12cc48..7904eeb 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -54,13 +54,9 @@ func (e *withStackTrace) Error() string { return e.err.Error() } -// DetailedError implements the [DetailedError] interface. -func (e *withStackTrace) DetailedError() string { - s := &strings.Builder{} - s.WriteString(e.err.Error()) - s.WriteString("\n") - s.WriteString(e.stack.String()) - return s.String() +// ErrorDetails implements the [DetailedError] interface. +func (e *withStackTrace) ErrorDetails() string { + return e.stack.String() } // Unwrap implements the Go 1.13 `Unwrap() error` method, returning diff --git a/wrapper.go b/wrapper.go index 75d8742..11335cb 100644 --- a/wrapper.go +++ b/wrapper.go @@ -30,6 +30,14 @@ func (e *withWrapper) Error() string { return s.String() } +// ErrorDetails implements the [DetailedError] interface. +func (e *withWrapper) ErrorDetails() string { + if dErr, ok := e.wrapper.(DetailedError); ok { + return dErr.ErrorDetails() + } + return "" +} + // Unwrap implements the Go 1.13 `Unwrap() error` method, returning // the wrapped error. // diff --git a/xerrors.go b/xerrors.go index 6b14618..ba04cb3 100644 --- a/xerrors.go +++ b/xerrors.go @@ -6,12 +6,15 @@ import ( // DetailedError represents an error that provides additional details // beyond the error message. -// -// The DetailedError method returns a longer, multi-line description -// of the error. It always ends with a new line. type DetailedError interface { error - DetailedError() string + + // ErrorDetails returns additional details about the error. It should not + // repeat the error message and should end with a newline. + // + // An empty string is returned if the error does not provide + // additional details. + ErrorDetails() string } // Message creates a simple error with the given message, without From 86f43681f61780731b21cc4327ac90b7504ababe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Mon, 28 Apr 2025 17:15:17 +0200 Subject: [PATCH 15/19] Remove the multi-error prefix to make the error message more concise. --- README.md | 4 ++-- multierror.go | 5 +---- multierror_test.go | 10 +++++----- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 286edc6..1b8e847 100644 --- a/README.md +++ b/README.md @@ -148,12 +148,12 @@ if len(input.Password) < 8 { } if err != nil { - fmt.Println(err.Error()) // the following errors occurred: [username cannot be empty, password must be at least 8 characters] + fmt.Println(err.Error()) // [username cannot be empty, password must be at least 8 characters] // Detailed output using xerrors.Print: xerrors.Print(err) // Output: - // Error: the following errors occurred: + // Error: [username cannot be empty, password must be at least 8 characters] // 1. Error: username cannot be empty // at main.validateInput (/path/to/your/file.go:XX) // ... stack trace ... diff --git a/multierror.go b/multierror.go index b84d629..d8c33ae 100644 --- a/multierror.go +++ b/multierror.go @@ -6,8 +6,6 @@ import ( "strings" ) -const multiErrorPrefix = "the following errors occurred:" - // Append appends the provided errors to an existing error or list of // errors. If `err` is not a [multiError], it will be converted into // one. Nil errors are ignored. It does not record a stack trace. @@ -57,8 +55,7 @@ type multiError []error // Error implements the [error] interface. func (e multiError) Error() string { var s strings.Builder - s.WriteString(multiErrorPrefix) - s.WriteString(" [") + s.WriteString("[") for n, err := range e { s.WriteString(err.Error()) if n < len(e)-1 { diff --git a/multierror_test.go b/multierror_test.go index eef26dd..ef2ccce 100644 --- a/multierror_test.go +++ b/multierror_test.go @@ -14,12 +14,12 @@ func TestAppend(t *testing.T) { want string wantNil bool }{ - {err: nil, errs: []error{Message("a"), Message("b")}, want: "the following errors occurred: [a, b]"}, + {err: nil, errs: []error{Message("a"), Message("b")}, want: "[a, b]"}, {err: nil, errs: []error{nil, Message("a")}, want: "a"}, - {err: Message("a"), errs: []error{Message("b"), Message("c")}, want: "the following errors occurred: [a, b, c]"}, + {err: Message("a"), errs: []error{Message("b"), Message("c")}, want: "[a, b, c]"}, {err: Message("a"), errs: nil, want: "a"}, - {err: multiError{Message("a"), Message("b")}, errs: nil, want: "the following errors occurred: [a, b]"}, - {err: multiError{Message("a"), Message("b")}, errs: []error{Message("c")}, want: "the following errors occurred: [a, b, c]"}, + {err: multiError{Message("a"), Message("b")}, errs: nil, want: "[a, b]"}, + {err: multiError{Message("a"), Message("b")}, errs: []error{Message("c")}, want: "[a, b, c]"}, {err: multiError{}, errs: []error{Message("a"), nil}, want: "a"}, {err: nil, errs: nil, wantNil: true}, {err: nil, errs: []error{nil, nil}, wantNil: true}, @@ -69,7 +69,7 @@ func TestMultiError_ErrorDetails(t *testing.T) { {errs: []error{}, want: ``}, {errs: []error{Message("a")}, want: "1. Error: a\n"}, {errs: []error{Message("a"), Message("b")}, want: "1. Error: a\n2. Error: b\n"}, - {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: the following errors occurred: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, + {errs: []error{Message("a"), multiError{Message("b"), Message("c")}}, want: "1. Error: a\n2. Error: [b, c]\n\t1. Error: b\n\t2. Error: c\n"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { From f8a24c33b6054ff1dd2b7f01622427cb1d58dc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Thu, 15 May 2025 00:23:39 +0200 Subject: [PATCH 16/19] Improve handling nil values in Joinf --- multierror.go | 10 +++------- xerrors.go | 6 ++++++ xerrors_go120_test.go | 3 +++ xerrors_test.go | 1 + 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/multierror.go b/multierror.go index d8c33ae..55c19fa 100644 --- a/multierror.go +++ b/multierror.go @@ -23,14 +23,10 @@ import ( func Append(err error, errs ...error) error { var me multiError if err != nil { - if merr, ok := err.(multiError); ok { - for _, e := range merr { - if e != nil { - me = append(me, e) - } - } + if mErr, ok := err.(multiError); ok { + me = mErr } else { - me = append(me, err) + me = multiError{err} } } for _, e := range errs { diff --git a/xerrors.go b/xerrors.go index ba04cb3..66b03c9 100644 --- a/xerrors.go +++ b/xerrors.go @@ -173,6 +173,12 @@ func Joinf(format string, args ...any) error { wErr.msg = err.Error() return wErr } + // Edge case: if multiple %w verbs are used, and all of them are nil. + if wErr == nil { + return err + } + // Edge case: if multiple %w verbs are used, and only one of them is + // not nil. return &withWrapper{ err: wErr, msg: err.Error(), diff --git a/xerrors_go120_test.go b/xerrors_go120_test.go index 3528ca0..eb35dbc 100644 --- a/xerrors_go120_test.go +++ b/xerrors_go120_test.go @@ -18,6 +18,9 @@ func TestJoinf_Go120(t *testing.T) { want string }{ {format: "multiple errors: %w: %w", args: []any{err1, err2}, want: "multiple errors: first error: second error"}, + {format: "wrapped multiple nil errors: %w %w", args: []any{nil, nil}, want: "wrapped multiple nil errors: %!w() %!w()"}, + {format: "first error nil: %w %w", args: []any{nil, err2}, want: "first error nil: %!w() second error"}, + {format: "second error nil: %w %w", args: []any{err1, nil}, want: "second error nil: first error %!w()"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { diff --git a/xerrors_test.go b/xerrors_test.go index ae8704b..8b9c6d1 100644 --- a/xerrors_test.go +++ b/xerrors_test.go @@ -192,6 +192,7 @@ func TestJoinf(t *testing.T) { {format: "simple error", args: nil, want: "simple error"}, {format: "error with value %d", args: []any{42}, want: "error with value 42"}, {format: "wrapped error: %w", args: []any{err}, want: "wrapped error: first error"}, + {format: "wrapped nil error: %w", args: []any{nil}, want: "wrapped nil error: %!w()"}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { From 551b4b9190805995e8579165d0c4afc90dd374dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Thu, 15 May 2025 02:59:44 +0200 Subject: [PATCH 17/19] Improve error formatting --- format.go | 86 ++++++++++++++++++++++++++++------------------ format_test.go | 6 ++-- multierror.go | 26 ++++---------- panic.go | 10 +----- stacktrace.go | 13 ++++--- stacktrace_test.go | 12 +++---- wrapper.go | 12 +++++-- 7 files changed, 85 insertions(+), 80 deletions(-) diff --git a/format.go b/format.go index 77f13db..c32ace2 100644 --- a/format.go +++ b/format.go @@ -1,7 +1,6 @@ package xerrors import ( - "bytes" "fmt" "io" "os" @@ -13,46 +12,50 @@ var errWriter io.Writer = os.Stderr // Print writes a formatted error to stderr. // -// If the error implements the [DetailedError] interface, the result -// of [DetailedError] is used for each wrapped error. Otherwise, the -// standard Error method is used. The formatted error can span -// multiple lines and always ends with a newline. +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. +// +// The formatted error can span multiple lines and always ends with +// a newline. func Print(err error) { - fprint(errWriter, err) + buf := &strings.Builder{} + writeErr(buf, err) + errWriter.Write([]byte(buf.String())) } // Sprint returns a formatted error as a string. // -// If the error implements the [DetailedError] interface, the result -// of [DetailedError] is used for each wrapped error. Otherwise, the -// standard Error method is used. The formatted error can span -// multiple lines and always ends with a newline. +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. +// +// The formatted error can span multiple lines and always ends with +// a newline. func Sprint(err error) string { - s := &strings.Builder{} - fprint(s, err) - return s.String() + buf := &strings.Builder{} + writeErr(buf, err) + return buf.String() } // Fprint writes a formatted error to the provided [io.Writer]. // -// If the error implements the [DetailedError] interface, the result -// of [DetailedError] is used for each wrapped error. Otherwise, the -// standard Error method is used. The formatted error can span -// multiple lines and always ends with a newline. +// If the error implements the [DetailedError] interface and returns +// a non-empty string, the returned details are added to each error +// in the chain. +// +// The formatted error can span multiple lines and always ends with +// a newline. func Fprint(w io.Writer, err error) (int, error) { - return fprint(w, err) + buf := &strings.Builder{} + writeErr(buf, err) + return w.Write([]byte(buf.String())) } -// fprint is a helper function that writes a formatted error to the -// given [io.Writer]. -// -// This function prints all errors in the chain that implement the -// [DetailedError] interface. The first error is printed using the -// standard Error method if it does not implement [DetailedError]. -func fprint(w io.Writer, err error) (int, error) { +// writeErr writes a formatted error to the provided strings.Builder. +func writeErr(buf *strings.Builder, err error) { const firstErrorPrefix = "Error: " const previousErrorPrefix = "Previous error: " - var buf bytes.Buffer first := true for err != nil { errMsg := err.Error() @@ -67,12 +70,15 @@ func fprint(w io.Writer, err error) (int, error) { buf.WriteString(previousErrorPrefix) } buf.WriteString(errMsg) - buf.WriteString("\n") - buf.WriteString(errDetails) + buf.WriteString("\n\t") + buf.WriteString(indent(errDetails)) + if !strings.HasSuffix(errDetails, "\n") { + buf.WriteByte('\n') + } } else { - // If an error does not have any details, then the Error() method - // should print all errors separated with ":", so there is no need - // to render each error other than the first one. + // If an error does not contain any details, do not print + // it, except for the first one. This is to avoid printing + // every wrapped error on a single line. if first { buf.WriteString(firstErrorPrefix) buf.WriteString(errMsg) @@ -86,11 +92,10 @@ func fprint(w io.Writer, err error) (int, error) { } break } - return w.Write(buf.Bytes()) } -// format is a helper function to format custom types when -// implementing [fmt.Formatter]. +// format is a helper function that formats a value according to the provided +// format state and verb. func format(s fmt.State, verb rune, v any) { f := []rune{'%'} for _, c := range []int{'-', '+', '#', ' ', '0'} { @@ -108,3 +113,16 @@ func format(s fmt.State, verb rune, v any) { f = append(f, verb) fmt.Fprintf(s, string(f), v) } + +// indent indents every line, except the first one, with a tab. +func indent(s string) string { + nl := strings.HasSuffix(s, "\n") + if nl { + s = s[:len(s)-1] + } + s = strings.ReplaceAll(s, "\n", "\n\t") + if nl { + s += "\n" + } + return s +} diff --git a/format_test.go b/format_test.go index d30906b..1fbed76 100644 --- a/format_test.go +++ b/format_test.go @@ -34,15 +34,15 @@ func TestFormat(t *testing.T) { }, { err: testErr{err: "err", details: "details"}, - want: "Error: err\ndetails\n", + want: "Error: err\n\tdetails\n", }, { err: testErr{err: "err", details: "details", wrapped: Message("wrapped")}, - want: "Error: err\ndetails\n", + want: "Error: err\n\tdetails\n", }, { err: testErr{err: "err", details: "details", wrapped: testErr{err: "wrapped err", details: "wrapped details"}}, - want: "Error: err\ndetails\nPrevious error: wrapped err\nwrapped details\n", + want: "Error: err\n\tdetails\nPrevious error: wrapped err\n\twrapped details\n", }, } for n, tt := range tests { diff --git a/multierror.go b/multierror.go index 55c19fa..8726bdb 100644 --- a/multierror.go +++ b/multierror.go @@ -62,18 +62,19 @@ func (e multiError) Error() string { return s.String() } -// ErrorDetails implements the [DetailedError] interface. +// ErrorDetails returns additional details about the error for +// the [ErrorDetails] function. func (e multiError) ErrorDetails() string { if len(e) == 0 { return "" } - var s strings.Builder + buf := &strings.Builder{} for n, err := range e.Unwrap() { - s.WriteString(strconv.Itoa(n + 1)) - s.WriteString(". ") - s.WriteString(indent(Sprint(err))) + buf.WriteString(strconv.Itoa(n + 1)) + buf.WriteString(". ") + writeErr(buf, err) } - return s.String() + return buf.String() } // Unwrap implements the Go 1.20 `Unwrap() []error` method, returning @@ -105,16 +106,3 @@ func (e multiError) Is(target error) bool { } return false } - -// indent indents every line, except the first one, with a tab. -func indent(s string) string { - nl := strings.HasSuffix(s, "\n") - if nl { - s = s[:len(s)-1] - } - s = strings.ReplaceAll(s, "\n", "\n\t") - if nl { - s += "\n" - } - return s -} diff --git a/panic.go b/panic.go index fc410d6..604c76d 100644 --- a/panic.go +++ b/panic.go @@ -10,7 +10,7 @@ import ( type PanicError interface { error - // Panic returns the value that caused the panic. + // Panic returns the raw panic value. Panic() any } @@ -61,11 +61,3 @@ func (e *panicError) Panic() any { func (e *panicError) Error() string { return fmt.Sprintf("panic: %v", e.panic) } - -// ErrorDetails implements the [DetailedError] interface. -func (e *panicError) ErrorDetails() string { - if dErr, ok := e.panic.(DetailedError); ok { - return dErr.ErrorDetails() - } - return "" -} diff --git a/stacktrace.go b/stacktrace.go index 7904eeb..c700f09 100644 --- a/stacktrace.go +++ b/stacktrace.go @@ -16,11 +16,11 @@ const stackTraceDepth = 128 func StackTrace(err error) Callers { var callers Callers for err != nil { - if e, ok := err.(interface{ StackTrace() Callers }); ok { - callers = e.StackTrace() + if st, ok := err.(interface{ StackTrace() Callers }); ok { + callers = st.StackTrace() } - if e, ok := err.(interface{ Unwrap() error }); ok { - err = e.Unwrap() + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() continue } break @@ -127,7 +127,6 @@ func (f Frame) Format(s fmt.State, verb rune) { // writeFrame writes a formatted stack frame to the given [io.Writer]. func (f Frame) writeFrame(w io.Writer) { - io.WriteString(w, "\tat ") io.WriteString(w, shortname(f.Function)) io.WriteString(w, " (") io.WriteString(w, f.File) @@ -195,8 +194,8 @@ func (c Callers) Format(s fmt.State, verb rune) { // writeTrace writes the stack trace to the provided [io.Writer]. func (c Callers) writeTrace(w io.Writer) { - frames := c.Frames() - for _, frame := range frames { + for _, frame := range c.Frames() { + io.WriteString(w, "at ") frame.writeFrame(w) io.WriteString(w, "\n") } diff --git a/stacktrace_test.go b/stacktrace_test.go index 2748afd..c85b1a1 100644 --- a/stacktrace_test.go +++ b/stacktrace_test.go @@ -85,16 +85,16 @@ func TestFrameFormat(t *testing.T) { want string regexp bool }{ - {format: "%s", want: "\tat function (file:42)"}, + {format: "%s", want: "function (file:42)"}, {format: "%f", want: "file"}, {format: "%d", want: "42"}, {format: "%n", want: "function"}, {format: "%+n", want: "package/function"}, {format: "%+n", want: "package/function"}, - {format: "%v", want: "\tat function (file:42)"}, + {format: "%v", want: "function (file:42)"}, {format: "%+v", want: "{File:file Line:42 Function:package/function}"}, {format: "%#v", want: "xerrors._Frame{File:\"file\", Line:42, Function:\"package/function\"}"}, - {format: "%q", want: "\"\\tat function (file:42)\""}, + {format: "%q", want: "\"function (file:42)\""}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { @@ -111,11 +111,11 @@ func TestCallersFormat(t *testing.T) { format string want string }{ - {format: "%s", want: `^\tat .*(\n\tat .*)+\n$`}, - {format: "%v", want: `^\tat .*(\n\tat .*)+\n$`}, + {format: "%s", want: `^at .*(\nat .*)+\n$`}, + {format: "%v", want: `^at .*(\nat .*)+\n$`}, {format: "%+v", want: `\[([0-9 ])+\]`}, {format: "%#v", want: `^xerrors\._Callers\{(0x[a-f0-9]+, )*(0x[a-f0-9]+)\}$`}, - {format: "%q", want: `^"\\tat .*(\\n\\tat .*)+\\n"$`}, + {format: "%q", want: `^"at .*(\\nat .*)+\\n"$`}, } for n, tt := range tests { t.Run(fmt.Sprintf("case-%d", n+1), func(t *testing.T) { diff --git a/wrapper.go b/wrapper.go index 11335cb..7a4b66a 100644 --- a/wrapper.go +++ b/wrapper.go @@ -32,8 +32,16 @@ func (e *withWrapper) Error() string { // ErrorDetails implements the [DetailedError] interface. func (e *withWrapper) ErrorDetails() string { - if dErr, ok := e.wrapper.(DetailedError); ok { - return dErr.ErrorDetails() + err := e.wrapper + for err != nil { + if dErr, ok := err.(DetailedError); ok { + return dErr.ErrorDetails() + } + if wErr, ok := err.(interface{ Unwrap() error }); ok { + err = wErr.Unwrap() + continue + } + break } return "" } From cd41378bb5a37448ad0700a504ab025a3c7a1b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Thu, 15 May 2025 02:59:52 +0200 Subject: [PATCH 18/19] Update README --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1b8e847..c2d960d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # go-xerrors -`go-xerrors` is an idiomatic, lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying work with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (including changes in Go 1.13 and 1.20), such as `errors.As`, `errors.Is`, and `errors.Unwrap`. +`go-xerrors` is a simple, idiomatic, lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying work with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (including changes in Go 1.13 and 1.20), such as `errors.As`, `errors.Is`, and `errors.Unwrap`. **Main Features:** +- **Simple and Lightweight**: Designed with simplicity in mind, the library has a small codebase with no external dependencies, making it easy to understand and integrate into any Go project. - **Stack Traces**: Automatically captures and attaches stack traces to errors upon creation, which significantly aids debugging and helps pinpoint the origin of issues. - **Multi-Errors**: Enables the aggregation of multiple errors into a single error instance, useful for reporting all failures from operations that involve multiple steps or components. - **Flexible Error Wrapping**: Provides ways to wrap errors with additional context or messages, while preserving the ability to inspect each underlying error individually. @@ -62,9 +63,9 @@ fmt.Print(trace) Output: ``` - at main.TestMain (/home/user/app/main_test.go:10) - at testing.tRunner (/home/user/go/src/testing/testing.go:1259) - at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) +at main.TestMain (/home/user/app/main_test.go:10) +at testing.tRunner (/home/user/go/src/testing/testing.go:1259) +at runtime.goexit (/home/user/go/src/runtime/asm_arm64.s:1133) ``` You can also explicitly add a stack trace to an existing error: @@ -76,7 +77,7 @@ errWithStack := xerrors.WithStackTrace(err, 0) // 0 skips no frames ### Wrapping Errors -The `xerrors.New` and `xerrors.Newf` functions can also wrap existing errors while preserving their stack traces: +The `xerrors.New` and `xerrors.Newf` functions can also wrap existing errors: ```go output, err := json.Marshal(data) @@ -94,19 +95,29 @@ if err != nil { } ``` +Note that wrapping multiple errors with `xerrors.Newf` is possible on Go 1.20 and later. + ### Creating Error Chains Without Stack Traces For situations where you don't need a stack trace (such as creating sentinel errors), use `xerrors.Join` and `xerrors.Joinf`: ```go -err := xerrors.Join("operation failed", existingError) +err := xerrors.Join("operation failed", otherErr) ``` -The key difference between `fmt.Errorf` and `xerrors.Newf`/`xerrors.Joinf` is that the latter functions preserve the error chain, whereas `fmt.Errorf` flattens it (i.e., its `Unwrap` method returns all underlying errors at once, instead of just the next one in the chain). +With formatted messages: + +```go +err := xerrors.Joinf("operation failed: %w", otherErr) +``` + +Note that wrapping multiple errors with `xerrors.Joinf` is possible on Go 1.20 and later. + +The main difference between Go's `fmt.Errorf` and `xerrors.Newf`/`xerrors.Joinf` is that the latter functions preserve the error chain, whereas `fmt.Errorf` flattens it (i.e., its `Unwrap` method returns all underlying errors at once instead of just the next one in the chain). ### Sentinel Errors -Sentinel errors are predefined error values representing specific, known failure conditions. `go-xerrors` provides `xerrors.Message` to create distinct sentinel error values with consistent messages: +Sentinel errors are predefined error values representing specific, known failure conditions. `go-xerrors` provides `xerrors.Message` to create distinct sentinel error values: ```go var ErrAccessDenied = xerrors.Message("access denied") @@ -186,7 +197,6 @@ func handleTask() (err error) { }) // ... potentially panicking code ... - panic("task failed") return nil } @@ -204,7 +214,6 @@ func handleTask() (err error) { }() // ... potentially panicking code ... - panic("task failed") return nil } From 80c7042f743f5f5a6c8abc58b400ad29b4e5c18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dobaczewski?= Date: Thu, 15 May 2025 03:12:10 +0200 Subject: [PATCH 19/19] Update README --- README.md | 12 ++++++------ doc.go | 13 ++++++------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c2d960d..dda6f14 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # go-xerrors -`go-xerrors` is a simple, idiomatic, lightweight Go package designed to enhance error handling in Go applications. It provides functions and types that simplify common error handling tasks by adding support for stack traces, combining multiple errors, and simplifying work with panics. `go-xerrors` maintains full compatibility with Go's standard error handling features (including changes in Go 1.13 and 1.20), such as `errors.As`, `errors.Is`, and `errors.Unwrap`. +`go-xerrors` is a simple, idiomatic, lightweight Go package that provides utilities for error handling. It offers functions and types to support stack traces, multi-errors, and simplified panic handling. The package is compatible with Go's standard error handling mechanisms, such as `errors.As`, `errors.Is`, and `errors.Unwrap`, including features from Go 1.13 and 1.20. **Main Features:** -- **Simple and Lightweight**: Designed with simplicity in mind, the library has a small codebase with no external dependencies, making it easy to understand and integrate into any Go project. -- **Stack Traces**: Automatically captures and attaches stack traces to errors upon creation, which significantly aids debugging and helps pinpoint the origin of issues. -- **Multi-Errors**: Enables the aggregation of multiple errors into a single error instance, useful for reporting all failures from operations that involve multiple steps or components. -- **Flexible Error Wrapping**: Provides ways to wrap errors with additional context or messages, while preserving the ability to inspect each underlying error individually. -- **Simplified Panic Handling**: Provides functions for converting recovered panic values into standard Go errors with stack traces, facilitating more robust error recovery logic. +- **Stack Traces**: Captures stack traces when creating errors to help locate the origin of issues during debugging +- **Multi-Errors**: Aggregates multiple errors into a single error instance while maintaining individual error context +- **Error Wrapping**: Wraps errors with additional context while preserving compatibility with `errors.Is`, `errors.As`, and `errors.Unwrap` +- **Panic Handling**: Converts panic values to standard Go errors with stack traces for structured error recovery +- **Zero Dependencies**: Implements error handling utilities with no external dependencies beyond the Go standard library --- diff --git a/doc.go b/doc.go index 02a1843..27500ca 100644 --- a/doc.go +++ b/doc.go @@ -1,8 +1,7 @@ -// Package xerrors is an idiomatic and lightweight Go package designed to -// enhance error handling in Go applications. It provides functions and types -// that simplify common error handling tasks by adding support for stack -// traces, combining multiple errors, and simplifying working with panics. -// The package maintains full compatibility with Go's standard error handling -// features (including changes in Go 1.13 and 1.20), such as errors.As, -// errors.Is, and errors.Unwrap. +// Package go-xerrors is a simple, idiomatic, lightweight Go package that +// provides utilities for error handling. It offers functions and types to +// support stack traces, multi-errors, and simplified panic handling. The +// package is compatible with Go's standard error handling mechanisms, such +// as errors.As, errors.Is, and errors.Unwrap, including features from Go +// 1.13 and 1.20. package xerrors