Skip to content

Commit

Permalink
Simplify support for custom errors with TraceSkip
Browse files Browse the repository at this point in the history
  • Loading branch information
alnvdl committed Mar 14, 2023
1 parent 953a5bf commit b86912f
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 76 deletions.
31 changes: 14 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,22 @@ Without terr | With terr
`errors.New("error")` | `terr.Newf("error")`
`fmt.Errorf("error: %w", err)` | `terr.Newf("error: %w", err)`
`[return] err` | `terr.Trace(err)`
`[return] &CustomError{}` | `terr.Trace(&CustomError{}, ...opts)`
`[return] &CustomError{}` | `terr.TraceSkip(&CustomError{}, 1)`

`terr.Newf` can receive multiple errors. In fact, it is just a very slim
wrapper around `fmt.Errorf`. Any traced error passed to `terr.Newf` will be
included in the error tracing tree, regardless of the `fmt` verb used for it.

`terr.Trace` on the other hand does nothing with the error it receive (no
wrapping and no masking), but it adds one level to the error tracing tree.
`terr.Trace` can also receive additional options to support custom errors:
`terr.WithLocation` and `terr.WithChildren`, which allow changing attributes of
the traced error.
`terr.Trace` and `terr.TraceSkip` on the other hand do nothing with the error
they receive (no wrapping and no masking), but they add one level to the error
tracing tree. `terr.TraceSkip` lets custom errors constructors return a traced
error with the location defined by skipping a number of stack frames.

To obtain the full trace, terr functions must be used consistently. If
`fmt.Errorf` is used at one point, the error tracing information will be reset
at that point, but Go's wrapped error tree will be preserved even in that case.

Examples are available showing all these functions in use[^2].
Examples are available showing these functions in use[^2].

In the glorious day error tracing is added to Go, and assuming it gets done in
a way that respects error handling as defined in Go 1.13+,
Expand All @@ -56,8 +55,8 @@ replacing the `terr` function calls with equivalent expressions.

### Tracing custom errors
Custom errors can be turned into traced errors as well by using
`terr.Trace(err, terr.WithLocation(file, line))` in constructor functions. An
example is available[^3].
`terr.TraceSkip(err, skip)` in constructor functions. An example is
available[^3].

### Printing errors
An error tracing tree can be printed with the special `%@` formatting verb. An
Expand Down Expand Up @@ -107,13 +106,12 @@ $ gofmt -w -r 'fmt.Errorf -> terr.Newf' .
$ goimports -w .
```

Adopting `terr.Trace` is harder, as it is impossible to write a simple gofmt
rewrite rule that works for all cases. So it has to be applied by hand
following these guidelines:
Adopting `terr.Trace` and `terr.TraceSkip` is harder, as it is impossible to
write a simple gofmt rewrite rule that works for all cases. So they have to be
applied by hand following these guidelines:
- `return err` becomes `return terr.Trace(err)`;
- `return NewCustomErr()` requires an adaptation in `NewCustomErr` to use
`terr.Trace`, possibly with options (usually only `terr.WithLocation`, but
`terr.WithChildren` is also available). An example is available[^3].
`terr.TraceSkip`. An example is available[^3].

### Getting rid of terr
Run the following commands in a directory tree to get rid of terr in all its
Expand All @@ -122,15 +120,14 @@ files (make sure `goimports`[^6] is installed first):
$ gofmt -w -r 'terr.Newf(a) -> errors.New(a)' .
$ gofmt -w -r 'terr.Newf -> fmt.Errorf' .
$ gofmt -w -r 'terr.Trace(a) -> a' .
$ gofmt -w -r 'terr.Trace(a, b) -> a' .
$ gofmt -w -r 'terr.Trace(a, b, c) -> a' .
$ gofmt -w -r 'terr.TraceSkip(a, b) -> a' .
$ goimports -w .
$ go mod tidy
```

[^1]: https://go.dev/blog/go1.13-errors
[^2]: https://pkg.go.dev/github.com/alnvdl/terr#pkg-examples
[^3]: https://pkg.go.dev/github.com/alnvdl/terr#example-Trace-CustomError
[^3]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceSkip
[^4]: https://pkg.go.dev/github.com/alnvdl/terr#example-package
[^5]: https://pkg.go.dev/github.com/alnvdl/terr#example-TraceTree
[^6]: https://pkg.go.dev/golang.org/x/tools/cmd/goimports
14 changes: 6 additions & 8 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package terr_test
import (
"errors"
"fmt"
"runtime"

"github.com/alnvdl/terr"
)
Expand Down Expand Up @@ -59,16 +58,15 @@ func (e *ValidationError) Error() string {
}

func NewValidationError(msg string) error {
_, file, line, _ := runtime.Caller(1)
return terr.Trace(&ValidationError{msg}, terr.WithLocation(file, line))
return terr.TraceSkip(&ValidationError{msg}, 1)
}

// This example shows how to add tracing information to custom error types
// using Trace and the WithLocation option. Custom error type constructors like
// NewValidationError can define a location for the errors they return. In this
// case, the location is being set to the location of the NewValidationError
// caller.
func ExampleTrace_customError() {
// using TraceSkip. Custom error type constructors like NewValidationError can
// define a number of stack frames to skip for defining the location of the
// traced errors they return. In this case, the location is being set to the
// location of the callor of NewValidationError.
func ExampleTraceSkip() {
// err will be annotated with the line number of the following line.
err := NewValidationError("x must be >= 0")
fmt.Printf("%@\n", err)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/alnvdl/terr

// Incomplete and/or incorrect release (should have been v0).
retract [v1.0.0, v1.0.12]
retract [v1.0.0, v1.0.13]

go 1.20
52 changes: 16 additions & 36 deletions terr.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ type location struct {
line int
}

func getCallerLocation() location {
_, file, line, _ := runtime.Caller(2)
func getCallerLocation(skip int) location {
_, file, line, _ := runtime.Caller(2 + skip)
return location{file, line}
}

Expand Down Expand Up @@ -102,48 +102,28 @@ func treeRepr(err error, depth int) []string {
// This function is equivalent to fmt.Errorf("...", ...). If used without verbs
// and additional arguments, it is equivalent to errors.New("...").
func Newf(format string, a ...any) error {
return newTracedError(fmt.Errorf(format, a...), a, getCallerLocation())
}

// A TraceOption allows customization of errors returned by the Trace function.
type TraceOption func(e *tracedError)

// WithLocation returns a traced error with the given location. This option can
// be used in custom error constructor functions, so they can return a traced
// error pointing at their callers.
func WithLocation(file string, line int) TraceOption {
return func(e *tracedError) {
e.location = location{file, line}
}
}

// WithChildren returns a traced error with the given traced errors appended as
// children Non-traced errors are ignored. This option can be used in custom
// error constructor functions to define the children traced errors for a
// traced error.
func WithChildren(children []error) TraceOption {
return func(e *tracedError) {
for _, child := range children {
if terr, ok := child.(*tracedError); ok {
e.children = append(e.children, terr)
}
}
}
return newTracedError(fmt.Errorf(format, a...), a, getCallerLocation(0))
}

// Trace returns a new traced error for err. If err is already a traced error,
// a new traced error will be returned containing err as a child traced error.
// opts is an optional series of TraceOptions to be applied to the traced
// error. No wrapping or masking takes place in this function.
func Trace(err error, opts ...TraceOption) error {
// No wrapping or masking takes place in this function.
func Trace(err error) error {
if err == nil {
return nil
}
terr := newTracedError(err, []any{err}, getCallerLocation())
for _, opt := range opts {
opt(terr)
return newTracedError(err, []any{err}, getCallerLocation(0))
}

// TraceSkip works exactly like Trace, but lets the caller skip a number of
// stack frames when detecting the error location, with 0 identifying the
// caller of TraceSkip. This function can be used in custom error constructor
// functions, so they can return a traced error pointing at their callers.
func TraceSkip(err error, skip int) error {
if err == nil {
return nil
}
return terr
return newTracedError(err, []any{err}, getCallerLocation(skip))
}

// ErrorTracer is an object capable of tracing an error's location and possibly
Expand Down
29 changes: 15 additions & 14 deletions terr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,22 @@ func TestTrace(t *testing.T) {
fmt.Sprintf("fail @ %s:%d", file, line+2),
fmt.Sprintf("\tfail @ %s:%d", file, line+1),
}, "\n"))
}

tracedErrOpts := terr.Trace(err,
terr.WithLocation("somefile.go", 123),
terr.WithChildren([]error{tracedErr}),
)
assertEquals(t, tracedErrOpts.Error(), "fail")
assertEquals(t, errors.Is(tracedErrOpts, err), true)
// tracedErr.Unwrap() should still return nil, because no wrapping took place.
assertErrorIsNil(t, errors.Unwrap(tracedErrOpts))
assertEquals(t, fmt.Sprintf("%@", tracedErrOpts), strings.Join([]string{
fmt.Sprintf("fail @ %s:%d", "somefile.go", 123),
func TestTraceSkip(t *testing.T) {
file, line := getLocation(0)
err := terr.Newf("fail")
newCustomError := func() error {
return terr.TraceSkip(err, 1)
}
customErr := newCustomError()
assertEquals(t, customErr.Error(), "fail")
assertEquals(t, errors.Is(customErr, err), true)
// customErr.Unwrap() should still return nil, because no wrapping took place.
assertErrorIsNil(t, errors.Unwrap(customErr))
assertEquals(t, fmt.Sprintf("%@", customErr), strings.Join([]string{
fmt.Sprintf("fail @ %s:%d", file, line+5),
fmt.Sprintf("\tfail @ %s:%d", file, line+1),
// tracedErrOpts included tracedErr as a child.
fmt.Sprintf("\tfail @ %s:%d", file, line+2),
fmt.Sprintf("\t\tfail @ %s:%d", file, line+1),
}, "\n"))
}

Expand Down Expand Up @@ -201,7 +202,7 @@ func TestNewfMultiple(t *testing.T) {

func TestNil(t *testing.T) {
assertErrorIsNil(t, terr.Trace(nil))
assertErrorIsNil(t, terr.Trace(nil, terr.WithLocation("somefile.go", 123)))
assertErrorIsNil(t, terr.TraceSkip(nil, 1))

assertTraceTreeEquals(t, terr.TraceTree(nil), nil)
}

0 comments on commit b86912f

Please sign in to comment.