Skip to content

Instantly share code, notes, and snippets.

@lawrencejones
Last active July 16, 2024 20:40
Show Gist options
  • Save lawrencejones/3a392f7116220a9799e55460fa57622d to your computer and use it in GitHub Desktop.
Save lawrencejones/3a392f7116220a9799e55460fa57622d to your computer and use it in GitHub Desktop.
Code examples to go with https://incident.io/blog/golang-errors
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))
}
@assembled-jesse
Copy link

Whew, thanks for the confirmation @lawrencejones!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment