-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add custom
errors
package with stack trace functionality (#5239)
* feat: a simple stacktrace utility Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * feat: custom errors package with new, errorf, wrapping, unwrapping and stacktrace Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * chore: update existing errors import (small subset) Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * chore: update comments Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * add errors into skip-files linter config Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * intoduce UnwrapTillCause to suffice the limitation of Unwrap Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * Revert "chore: update existing errors import (small subset)" This reverts commit d27f017. Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * revert makefile && golangcilint file Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * apply PR feedbacks Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * stacktrace and errors test Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * fix typo Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * update stacktrace testing regex Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * add lint ignore for standard errors import inside errors pkg Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * [test files] add copyright headers Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * add no lint to avoid false misspell detection of keyword Tast Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * update stacktrace output test line number with regex pattern Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * return pc slice with reduced capacity Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * segregate formatted vs non formatted methods Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com> * update with only f functions Signed-off-by: Bisakh Mondal <bisakhmondal00@gmail.com>
- Loading branch information
1 parent
dd77331
commit 18b4dc3
Showing
4 changed files
with
368 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
// Copyright (c) The Thanos Authors. | ||
// Licensed under the Apache License 2.0. | ||
|
||
//nolint | ||
// The idea of writing errors package in thanos is highly motivated from the Tast project of Chromium OS Authors. However, instead of | ||
// copying the package, we end up writing our own simplified logic borrowing some ideas from the errors and github.com/pkg/errors. | ||
// A big thanks to all of them. | ||
|
||
// Package errors provides basic utilities to manipulate errors with a useful stacktrace. It combines the | ||
// benefits of errors.New and fmt.Errorf world into a single package. | ||
package errors | ||
|
||
import ( | ||
//lint:ignore faillint Custom errors package needs to import standard library errors. | ||
"errors" | ||
"fmt" | ||
"strings" | ||
) | ||
|
||
// base is the fundamental struct that implements the error interface and the acts as the backbone of this errors package. | ||
type base struct { | ||
// info contains the error message passed through calls like errors.Wrap, errors.New. | ||
info string | ||
// stacktrace stores information about the program counters - i.e. where this error was generated. | ||
stack stacktrace | ||
// err is the actual error which is being wrapped with a stacktrace and message information. | ||
err error | ||
} | ||
|
||
// Error implements the error interface. | ||
func (b *base) Error() string { | ||
if b.err != nil { | ||
return fmt.Sprintf("%s: %s", b.info, b.err.Error()) | ||
} | ||
return b.info | ||
} | ||
|
||
// Unwrap implements the error Unwrap interface. | ||
func (b *base) Unwrap() error { | ||
return b.err | ||
} | ||
|
||
// Format implements the fmt.Formatter interface to support the formatting of an error chain with "%+v" verb. | ||
// Whenever error is printed with %+v format verb, stacktrace info gets dumped to the output. | ||
func (b *base) Format(s fmt.State, verb rune) { | ||
if verb == 'v' && s.Flag('+') { | ||
s.Write([]byte(formatErrorChain(b))) | ||
return | ||
} | ||
|
||
s.Write([]byte(b.Error())) | ||
} | ||
|
||
// Newf formats according to a format specifier and returns a new error with a stacktrace | ||
// with recent call frames. Each call to New returns a distinct error value even if the text is | ||
// identical. An alternative of the errors.New function. | ||
// | ||
// If no args have been passed, it is same as `New` function without formatting. Character like | ||
// '%' still has to be escaped in that scenario. | ||
func Newf(format string, args ...interface{}) error { | ||
return &base{ | ||
info: fmt.Sprintf(format, args...), | ||
stack: newStackTrace(), | ||
err: nil, | ||
} | ||
} | ||
|
||
// Wrapf returns a new error by formatting the error message with the supplied format specifier | ||
// and wrapping another error with a stacktrace containing recent call frames. | ||
// | ||
// If cause is nil, this is the same as fmt.Errorf. If no args have been passed, it is same as `Wrap` | ||
// function without formatting. Character like '%' still has to be escaped in that scenario. | ||
func Wrapf(cause error, format string, args ...interface{}) error { | ||
return &base{ | ||
info: fmt.Sprintf(format, args...), | ||
stack: newStackTrace(), | ||
err: cause, | ||
} | ||
} | ||
|
||
// Cause returns the result of repeatedly calling the Unwrap method on err, if err's | ||
// type implements an Unwrap method. Otherwise, Cause returns the last encountered error. | ||
// The difference between Unwrap and Cause is the first one performs unwrapping of one level | ||
// but Cause returns the last err (whether it's nil or not) where it failed to assert | ||
// the interface containing the Unwrap method. | ||
// This is a replacement of errors.Cause without the causer interface from pkg/errors which | ||
// actually can be sufficed through the errors.Is function. But considering some use cases | ||
// where we need to peel off all the external layers applied through errors.Wrap family, | ||
// it is useful ( where external SDK doesn't use errors.Is internally). | ||
func Cause(err error) error { | ||
for err != nil { | ||
e, ok := err.(interface { | ||
Unwrap() error | ||
}) | ||
if !ok { | ||
return err | ||
} | ||
err = e.Unwrap() | ||
} | ||
return nil | ||
} | ||
|
||
// formatErrorChain formats an error chain. | ||
func formatErrorChain(err error) string { | ||
var buf strings.Builder | ||
for err != nil { | ||
if e, ok := err.(*base); ok { | ||
buf.WriteString(fmt.Sprintf("%s\n%v", e.info, e.stack)) | ||
err = e.err | ||
} else { | ||
buf.WriteString(fmt.Sprintf("%s\n", err.Error())) | ||
err = nil | ||
} | ||
} | ||
return buf.String() | ||
} | ||
|
||
// The functions `Is`, `As` & `Unwrap` provides a thin wrapper around the builtin errors | ||
// package in go. Just for sake of completeness and correct autocompletion behaviors from | ||
// IDEs they have been wrapped using functions instead of using variable to reference them | ||
// as first class functions (eg: var Is = errros.Is ). | ||
|
||
// Is is a wrapper of built-in errors.Is. It reports whether any error in err's | ||
// chain matches target. The chain consists of err itself followed by the sequence | ||
// of errors obtained by repeatedly calling Unwrap. | ||
func Is(err, target error) bool { | ||
return errors.Is(err, target) | ||
} | ||
|
||
// As is a wrapper of built-in errors.As. It finds the first error in err's | ||
// chain that matches target, and if one is found, sets target to that error | ||
// value and returns true. Otherwise, it returns false. | ||
func As(err error, target interface{}) bool { | ||
return errors.As(err, target) | ||
} | ||
|
||
// Unwrap is a wrapper of built-in errors.Unwrap. Unwrap returns the result of | ||
// calling the Unwrap method on err, if err's type contains an Unwrap method | ||
// returning error. Otherwise, Unwrap returns nil. | ||
func Unwrap(err error) error { | ||
return errors.Unwrap(err) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
// Copyright (c) The Thanos Authors. | ||
// Licensed under the Apache License 2.0. | ||
|
||
package errors | ||
|
||
import ( | ||
//lint:ignore faillint Custom errors package tests need to import standard library errors. | ||
stderrors "errors" | ||
"fmt" | ||
"regexp" | ||
"strconv" | ||
"testing" | ||
|
||
"github.com/thanos-io/thanos/pkg/testutil" | ||
) | ||
|
||
const msg = "test_error_message" | ||
const wrapper = "test_wrapper" | ||
|
||
func TestNewf(t *testing.T) { | ||
err := Newf(msg) | ||
testutil.Equals(t, err.Error(), msg, "the root error message must match") | ||
|
||
reg := regexp.MustCompile(msg + `[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestNewf .*\/pkg\/errors\/errors_test\.go:\d+`) | ||
testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.New") | ||
} | ||
|
||
func TestNewfFormatted(t *testing.T) { | ||
fmtMsg := msg + " key=%v" | ||
expectedMsg := msg + " key=value" | ||
|
||
err := Newf(fmtMsg, "value") | ||
testutil.Equals(t, err.Error(), expectedMsg, "the root error message must match") | ||
reg := regexp.MustCompile(expectedMsg + `[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestNewfFormatted .*\/pkg\/errors\/errors_test\.go:\d+`) | ||
testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.New with format string") | ||
} | ||
|
||
func TestWrapf(t *testing.T) { | ||
err := Newf(msg) | ||
err = Wrapf(err, wrapper) | ||
|
||
expectedMsg := wrapper + ": " + msg | ||
testutil.Equals(t, err.Error(), expectedMsg, "the root error message must match") | ||
|
||
reg := regexp.MustCompile(`test_wrapper[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestWrapf .*\/pkg\/errors\/errors_test\.go:\d+ | ||
[[:ascii:]]+test_error_message[ \n]+> github\.com\/thanos-io\/thanos\/pkg\/errors\.TestWrapf .*\/pkg\/errors\/errors_test\.go:\d+`) | ||
|
||
testutil.Equals(t, reg.MatchString(fmt.Sprintf("%+v", err)), true, "matching stacktrace in errors.Wrap") | ||
} | ||
|
||
func TestUnwrap(t *testing.T) { | ||
// test with base error | ||
err := Newf(msg) | ||
|
||
for i, tc := range []struct { | ||
err error | ||
expected string | ||
isNil bool | ||
}{ | ||
{ | ||
// no wrapping | ||
err: err, | ||
isNil: true, | ||
}, | ||
{ | ||
err: Wrapf(err, wrapper), | ||
expected: "test_error_message", | ||
}, | ||
{ | ||
err: Wrapf(Wrapf(err, wrapper), wrapper), | ||
expected: "test_wrapper: test_error_message", | ||
}, | ||
// check primitives errors | ||
{ | ||
err: stderrors.New("std-error"), | ||
isNil: true, | ||
}, | ||
{ | ||
err: Wrapf(stderrors.New("std-error"), wrapper), | ||
expected: "std-error", | ||
}, | ||
{ | ||
err: nil, | ||
isNil: true, | ||
}, | ||
} { | ||
t.Run("TestCase"+strconv.Itoa(i), func(t *testing.T) { | ||
unwrapped := Unwrap(tc.err) | ||
if tc.isNil { | ||
testutil.Equals(t, unwrapped, nil) | ||
return | ||
} | ||
testutil.Equals(t, unwrapped.Error(), tc.expected, "Unwrap must match expected output") | ||
}) | ||
} | ||
} | ||
|
||
func TestCause(t *testing.T) { | ||
// test with base error that implements interface containing Unwrap method | ||
err := Newf(msg) | ||
|
||
for i, tc := range []struct { | ||
err error | ||
expected string | ||
isNil bool | ||
}{ | ||
{ | ||
// no wrapping | ||
err: err, | ||
isNil: true, | ||
}, | ||
{ | ||
err: Wrapf(err, wrapper), | ||
isNil: true, | ||
}, | ||
{ | ||
err: Wrapf(Wrapf(err, wrapper), wrapper), | ||
isNil: true, | ||
}, | ||
// check primitives errors | ||
{ | ||
err: stderrors.New("std-error"), | ||
expected: "std-error", | ||
}, | ||
{ | ||
err: Wrapf(stderrors.New("std-error"), wrapper), | ||
expected: "std-error", | ||
}, | ||
{ | ||
err: nil, | ||
isNil: true, | ||
}, | ||
} { | ||
t.Run("TestCase"+strconv.Itoa(i), func(t *testing.T) { | ||
cause := Cause(tc.err) | ||
if tc.isNil { | ||
testutil.Equals(t, cause, nil) | ||
return | ||
} | ||
testutil.Equals(t, cause.Error(), tc.expected, "Cause must match expected output") | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
// Copyright (c) The Thanos Authors. | ||
// Licensed under the Apache License 2.0. | ||
|
||
package errors | ||
|
||
import ( | ||
"fmt" | ||
"runtime" | ||
"strings" | ||
) | ||
|
||
// stacktrace holds a snapshot of program counters. | ||
type stacktrace []uintptr | ||
|
||
// newStackTrace captures a stack trace. It skips first 3 frames to record the | ||
// snapshot of the stack trace at the origin of a particular error. It tries to | ||
// record maximum 16 frames (if available). | ||
func newStackTrace() stacktrace { | ||
const stackDepth = 16 // record maximum 16 frames (if available). | ||
|
||
pc := make([]uintptr, stackDepth) | ||
// using skip=3 for not to count the program counter address of | ||
// 1. the respective function from errors package (eg. errors.New) | ||
// 2. newStacktrace itself | ||
// 3. the function used in runtime.Callers | ||
n := runtime.Callers(3, pc) | ||
|
||
// this approach is taken to reduce long term memory footprint (obtained through escape analysis). | ||
// We are returning a new slice by re-slicing the pc with the required length and capacity (when the | ||
// no of returned callFrames is less that stackDepth). This uses less memory compared to pc[:n] as | ||
// the capacity of new slice is inherited from the parent slice if not specified. | ||
return stacktrace(pc[:n:n]) | ||
} | ||
|
||
// String implements the fmt.Stringer interface to provide formatted text output. | ||
func (s stacktrace) String() string { | ||
var buf strings.Builder | ||
|
||
// CallersFrames takes the slice of Program Counter addresses returned by Callers to | ||
// retrieve function/file/line information. | ||
cf := runtime.CallersFrames(s) | ||
for { | ||
// more indicates if the next call will be successful or not. | ||
frame, more := cf.Next() | ||
// used formatting scheme <`>`space><function name><tab><filepath><:><line><newline> for example: | ||
// > testing.tRunner /home/go/go1.17.8/src/testing/testing.go:1259 | ||
buf.WriteString(fmt.Sprintf("> %s\t%s:%d\n", frame.Func.Name(), frame.File, frame.Line)) | ||
if !more { | ||
break | ||
} | ||
} | ||
return buf.String() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
// Copyright (c) The Thanos Authors. | ||
// Licensed under the Apache License 2.0. | ||
|
||
package errors | ||
|
||
import ( | ||
"strings" | ||
"testing" | ||
) | ||
|
||
func caller() stacktrace { | ||
return newStackTrace() | ||
} | ||
|
||
func TestStacktraceOutput(t *testing.T) { | ||
st := caller() | ||
expectedPhrase := "/pkg/errors/stacktrace_test.go:16" | ||
if !strings.Contains(st.String(), expectedPhrase) { | ||
t.Fatalf("expected %v phrase into the stacktrace, received stacktrace: \n%v", expectedPhrase, st.String()) | ||
} | ||
} | ||
|
||
func TestStacktraceProgramCounterLen(t *testing.T) { | ||
st := caller() | ||
output := st.String() | ||
lines := len(strings.Split(strings.TrimSuffix(output, "\n"), "\n")) | ||
if len(st) != lines { | ||
t.Fatalf("output lines vs program counter size mismatch: program counter size %v, output lines %v", len(st), lines) | ||
} | ||
} |