Skip to content

Instantly share code, notes, and snippets.

@lawrencejones
Last active March 27, 2024 14:49
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • 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))
}
@kainosnoema
Copy link

This is excellent. Maybe another errors lib isn't needed, but... this is just what we needed. 🤷‍♂️

@enumag
Copy link

enumag commented Jun 13, 2023

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?

@tw1nk
Copy link

tw1nk commented Sep 7, 2023

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

@assembled-jesse
Copy link

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

@lawrencejones
Copy link
Author

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 🙏

@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