Our incident.io pkg/errors, as explained in:
-
-
Save lawrencejones/3a392f7116220a9799e55460fa57622d to your computer and use it in GitHub Desktop.
package errors | |
import ( | |
"fmt" | |
"reflect" | |
"runtime" | |
"unsafe" | |
"github.com/pkg/errors" | |
) | |
// Export a number of functions or variables from pkg/errors. We want people to be able to | |
// use them, if only via the entrypoints we've vetted in this file. | |
var ( | |
As = errors.As | |
Is = errors.Is | |
Cause = errors.Cause | |
Unwrap = errors.Unwrap | |
) | |
// StackTrace should be aliases rather than newtype'd, so it can work with any of the | |
// functions we export from pkg/errors. | |
type StackTrace = errors.StackTrace | |
type StackTracer interface { | |
StackTrace() errors.StackTrace | |
} | |
// Sentinel is used to create compile-time errors that are intended to be value only, with | |
// no associated stack trace. | |
func Sentinel(msg string, args ...interface{}) error { | |
return fmt.Errorf(msg, args...) | |
} | |
// New acts as pkg/errors.New does, producing a stack traced error, but supports | |
// interpolating of message parameters. Use this when you want the stack trace to start at | |
// the place you create the error. | |
func New(msg string, args ...interface{}) error { | |
return PopStack(errors.New(fmt.Sprintf(msg, args...))) | |
} | |
// Wrap creates a new error from a cause, decorating the original error message with a | |
// prefix. | |
// | |
// It differs from the pkg/errors Wrap/Wrapf by idempotently creating a stack trace, | |
// meaning we won't create another stack trace when there is already a stack trace present | |
// that matches our current program position. | |
func Wrap(cause error, msg string, args ...interface{}) error { | |
causeStackTracer := new(StackTracer) | |
if errors.As(cause, causeStackTracer) { | |
// If our cause has set a stack trace, and that trace is a child of our own function | |
// as inferred by prefix matching our current program counter stack, then we only want | |
// to decorate the error message rather than add a redundant stack trace. | |
if ancestorOfCause(callers(1), (*causeStackTracer).StackTrace()) { | |
return errors.WithMessagef(cause, msg, args...) // no stack added, no pop required | |
} | |
} | |
// Otherwise we can't see a stack trace that represents ourselves, so let's add one. | |
return PopStack(errors.Wrapf(cause, msg, args...)) | |
} | |
// ancestorOfCause returns true if the caller looks to be an ancestor of the given stack | |
// trace. We check this by seeing whether our stack prefix-matches the cause stack, which | |
// should imply the error was generated directly from our goroutine. | |
func ancestorOfCause(ourStack []uintptr, causeStack errors.StackTrace) bool { | |
// Stack traces are ordered such that the deepest frame is first. We'll want to check | |
// for prefix matching in reverse. | |
// | |
// As an example, imagine we have a prefix-matching stack for ourselves: | |
// [ | |
// "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", | |
// "github.com/incident-io/core/server/pkg/errors_test.TestSuite", | |
// "testing.tRunner", | |
// "runtime.goexit" | |
// ] | |
// | |
// We'll want to compare this against an error cause that will have happened further | |
// down the stack. An example stack trace from such an error might be: | |
// [ | |
// "github.com/incident-io/core/server/pkg/errors.New", | |
// "github.com/incident-io/core/server/pkg/errors_test.glob..func1.2.2.2.1",, | |
// "github.com/onsi/ginkgo/internal/leafnodes.(*runner).runSync", | |
// "github.com/incident-io/core/server/pkg/errors_test.TestSuite", | |
// "testing.tRunner", | |
// "runtime.goexit" | |
// ] | |
// | |
// They prefix match, but we'll have to handle the match carefully as we need to match | |
// from back to forward. | |
// We can't possibly prefix match if our stack is larger than the cause stack. | |
if len(ourStack) > len(causeStack) { | |
return false | |
} | |
// We know the sizes are compatible, so compare program counters from back to front. | |
for idx := 0; idx < len(ourStack); idx++ { | |
if ourStack[len(ourStack)-1-idx] != (uintptr)(causeStack[len(causeStack)-1-idx]) { | |
return false | |
} | |
} | |
// All comparisons checked out, these stacks match | |
return true | |
} | |
func callers(skip int) []uintptr { | |
pc := make([]uintptr, 32) // assume we'll have at most 32 frames | |
n := runtime.Callers(skip+3, pc) // capture those frames, skipping runtime.Callers, ourself and the calling function | |
return pc[:n] // return everything that we captured | |
} | |
// RecoverPanic turns a panic into an error, adjusting the stacktrace so it originates at | |
// the line that caused it. | |
// | |
// Example: | |
// | |
// func Do() (err error) { | |
// defer func() { | |
// errors.RecoverPanic(recover(), &err) | |
// }() | |
// } | |
func RecoverPanic(r interface{}, errPtr *error) { | |
var err error | |
if r != nil { | |
if panicErr, ok := r.(error); ok { | |
err = errors.Wrap(panicErr, "caught panic") | |
} else { | |
err = errors.New(fmt.Sprintf("caught panic: %v", r)) | |
} | |
} | |
if err != nil { | |
// Pop twice: once for the errors package, then again for the defer function we must | |
// run this under. We want the stacktrace to originate at the source of the panic, not | |
// in the infrastructure that catches it. | |
err = PopStack(err) // errors.go | |
err = PopStack(err) // defer | |
*errPtr = err | |
} | |
} | |
// PopStack removes the top of the stack from an errors stack trace. | |
func PopStack(err error) error { | |
if err == nil { | |
return err | |
} | |
// We want to remove us, the internal/errors.New function, from the error stack we just | |
// produced. There's no official way of reaching into the error and adjusting this, as | |
// the stack is stored as a private field on an unexported struct. | |
// | |
// This does some unsafe badness to adjust that field, which should not be repeated | |
// anywhere else. | |
stackField := reflect.ValueOf(err).Elem().FieldByName("stack") | |
if stackField.IsZero() { | |
return err | |
} | |
stackFieldPtr := (**[]uintptr)(unsafe.Pointer(stackField.UnsafeAddr())) | |
// Remove the first of the frames, dropping 'us' from the error stack trace. | |
frames := (**stackFieldPtr)[1:] | |
// Assign to the internal stack field | |
*stackFieldPtr = &frames | |
return err | |
} |
package errors_test | |
import ( | |
"fmt" | |
"github.com/incident-io/core/server/pkg/errors" | |
. "github.com/onsi/ginkgo" | |
. "github.com/onsi/gomega" | |
) | |
func getStackTraces(err error) []errors.StackTrace { | |
traces := []errors.StackTrace{} | |
if err, ok := err.(errors.StackTracer); ok { | |
traces = append(traces, err.StackTrace()) | |
} | |
if err := errors.Unwrap(err); err != nil { | |
traces = append(traces, getStackTraces(err)...) | |
} | |
return traces | |
} | |
var _ = Describe("errors", func() { | |
Describe("New", func() { | |
It("generates an error with a stack trace", func() { | |
err := errors.New("oops") | |
Expect(getStackTraces(err)).To(HaveLen(1)) | |
}) | |
}) | |
Describe("Wrap", func() { | |
Context("when cause has no stack trace", func() { | |
It("wraps the error and takes stack trace", func() { | |
err := errors.Wrap(fmt.Errorf("cause"), "description") | |
Expect(err.Error()).To(Equal("description: cause")) | |
cause := errors.Cause(err) | |
Expect(cause).To(MatchError("cause")) | |
Expect(getStackTraces(err)).To(HaveLen(1)) | |
}) | |
}) | |
Context("when cause has stack trace", func() { | |
Context("which is not an ancestor of our own", func() { | |
It("creates a new stack trace", func() { | |
errChan := make(chan error) | |
go func() { | |
errChan <- errors.New("unrelated") // created with a stack trace | |
}() | |
err := errors.Wrap(<-errChan, "helpful description") | |
Expect(err.Error()).To(Equal("helpful description: unrelated")) | |
Expect(getStackTraces(err)).To(HaveLen(2)) | |
}) | |
}) | |
Context("with a frame from our current method", func() { | |
It("does not create new stack trace", func() { | |
err := errors.Wrap(errors.New("related"), "helpful description") | |
Expect(err.Error()).To(Equal("helpful description: related")) | |
Expect(getStackTraces(err)).To(HaveLen(1)) | |
}) | |
}) | |
}) | |
}) | |
}) |
package errors_test | |
import ( | |
"context" | |
"testing" | |
"github.com/incident-io/core/server/pkg/pkgdb" | |
"github.com/incident-io/core/server/pkg/spec" | |
) | |
var ( | |
ctx context.Context | |
pg *pkgdb.Postgres | |
tx *pkgdb.Postgres | |
) | |
func TestSuite(t *testing.T) { | |
spec.NewSuite(t, &ctx, &pg, &tx, spec.WithEnforceSafeDB(false)) | |
} |
First step, then: we created our own pkg/errors, and applied a linting rule that banned any imports of errors or github.com/pkg/errors to ensure consistency.
What linting rule did you use to achieve this?
with golangci-lint you could do something like this:
linters-settings:
depguard:
rules:
main:
deny:
- pkg: "errors"
desc: it doesn't provide callstacks
- pkg: "github.com/pkg/errors"
desc: it duplicates callstacks
forbidigo:
forbid:
- ^print.*$
- p: ^fmt\.Errorf.*$
msg: it doesn't provide callstacks
Late to the party but thanks for writing this up! In the ancestorOfCause
the pc comparison doesn't appear to use idx
at all and just checks the same frame repeatedly. I modified the tests not to use any external deps, and when I did the creates a new stack trace
test failed until I adjusted the loop to use (1 + idx)
. Not sure if I'm tripping or if the code in the gist
is outdated, but throwing it out there
Haha @assembled-jesse you are not tripping, this is my bad: code has an error in it that we've since fixed in our version.
Will update now 🙏
Whew, thanks for the confirmation @lawrencejones!
This is excellent. Maybe another errors lib isn't needed, but... this is just what we needed. 🤷♂️