Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add errors package #169

Open
wants to merge 6 commits into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package errors

import (
"context"
"fmt"
"runtime"

bugsnag_errors "github.com/bugsnag/bugsnag-go/v2/errors"
"github.com/sirupsen/logrus"
)

// Fields can be attached to errors like this:
// errors.Wrap(err, "invalid value", errors.Fields{"value": value})
type Fields map[string]interface{}

type ErrorWithDetails interface {
ErrorDetails() map[string]interface{}
}

type baseError struct {
err error
message string
fields Fields
stack []uintptr
}

func (e *baseError) Error() string {
return e.message
}

func (e *baseError) Unwrap() error {
return e.err
}

func (e *baseError) Callers() []uintptr {
return findStackFromError(e) // lazy stack lookup in wrapped errors
}

func (e *baseError) LogFields() logrus.Fields {
return logrus.Fields(e.fields)
}

var _ bugsnag_errors.ErrorWithCallers = &baseError{}

// Errorf formats according to a format specifier and returns the string
// as a value that satisfies error.
// Errorf also records the stack trace at the point it was called.
func Errorf(format string, args ...interface{}) error {
return &baseError{
message: fmt.Sprintf(format, args...),
stack: captureStack(),
}
}

// Wrap returns an error annotating err with a stack trace
// at the point Wrap is called, and the supplied message.
// If err is nil, Wrap returns nil.
func Wrap(err error, message string, fields ...Fields) error {
if err == nil {
return nil
}

return &baseError{
err: err,
message: fmt.Sprintf("%s: %s", message, err.Error()),
fields: mergeFieldsCtx(nil, err, fields...),
stack: captureStack(),
}
}

// WrapCtx returns an error annotating err with a stack trace and log fields.
// The log fields are captured from context.Context and arguments.
// If err is nil, WrapCtx returns nil.
func WrapCtx(ctx context.Context, err error, message string, fields ...Fields) error {
if err == nil {
return nil
}

return &baseError{
err: err,
message: message + ": " + err.Error(),
fields: mergeFieldsCtx(ctx, err, fields...),
stack: captureStack(),
}
}

// With returns an error annotating err with a stack trace and log fields.
// If err is nil, With returns nil.
func With(err error, fields ...Fields) error {
if err == nil {
return nil
}

return &baseError{
err: err,
message: err.Error(),
fields: mergeFieldsCtx(nil, err, fields...),
stack: captureStack(),
}
}

// WithCtx returns an error annotating err with a stack trace and log fields.
// The log fields are captured from context.Context and arguments.
// If err is nil, WithCtx returns nil.
func WithCtx(ctx context.Context, err error, fields ...Fields) error {
if err == nil {
return nil
}

return &baseError{
err: err,
message: err.Error(),
fields: mergeFieldsCtx(ctx, err, fields...),
stack: captureStack(),
}
}

func captureStack() []uintptr {
var pcs [32]uintptr
n := runtime.Callers(3, pcs[:]) // Wrap/WrapCtx -> findStack -> runtime.Callers
return pcs[0:n]
}
94 changes: 94 additions & 0 deletions errors/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package errors

import (
"context"
stderrors "errors"
"testing"

"github.com/stretchr/testify/require"
)

func TestNew(t *testing.T) {
err := New("MSG")
require.Equal(t, "MSG", err.Error())
}

func TestErrorf(t *testing.T) {
err := Errorf("MSG %s", "ME")
require.Equal(t, "MSG ME", err.Error())
}

func TestWrap_Nil(t *testing.T) {
err := Wrap(nil, "")
require.Nil(t, err)
}

func TestWrap(t *testing.T) {
err := Wrap(stderrors.New("inner"), "outer")
require.NotNil(t, err)
require.Equal(t, "outer: inner", err.Error())
}

func TestWrapJoined(t *testing.T) {
err := Wrap(stderrors.Join(New("inner 1"), New("inner 2")), "outer")
require.NotNil(t, err)
require.Equal(t, "outer: inner 1\ninner 2", err.Error())
}

func TestJoinWrapped(t *testing.T) {
err := stderrors.Join(New("first"), Wrap(New("inner"), "outer"))
require.NotNil(t, err)
require.Equal(t, "first\nouter: inner", err.Error())
}

func TestWrapCtx_Nil(t *testing.T) {
ctx := context.Background()
err := WrapCtx(ctx, nil, "")
require.Nil(t, err)
}

func TestWrapCtx(t *testing.T) {
ctx := context.Background()
err := WrapCtx(ctx, stderrors.New("inner"), "outer")
require.NotNil(t, err)
require.Equal(t, "outer: inner", err.Error())
}

func TestWrapCtxJoined(t *testing.T) {
ctx := context.Background()
err := WrapCtx(ctx, stderrors.Join(New("first"), New("second")), "outer")
require.NotNil(t, err)
require.Equal(t, "outer: first\nsecond", err.Error())
}

func TestWithCtx(t *testing.T) {
ctx := context.Background()
err := WithCtx(ctx, stderrors.New("inner"), Fields{"key": "val"})
require.NotNil(t, err)
require.Equal(t, "inner", err.Error())

require.Equal(t, Fields{"key": "val"}, FieldsFromError(err))
}

func TestWithCtxJoined(t *testing.T) {
ctx := context.Background()
err := WithCtx(ctx, stderrors.Join(New("first"), New("second")), Fields{"key": "val"})
require.NotNil(t, err)
require.Equal(t, "first\nsecond", err.Error())
require.Equal(t, Fields{"key": "val"}, FieldsFromError(err))
}

func TestWith(t *testing.T) {
err := With(stderrors.New("inner"), Fields{"key": "val"})
require.NotNil(t, err)
require.Equal(t, "inner", err.Error())

require.Equal(t, Fields{"key": "val"}, FieldsFromError(err))
}

func TestWithJoined(t *testing.T) {
err := With(stderrors.Join(New("first"), New("second")), Fields{"key": "val"})
require.NotNil(t, err)
require.Equal(t, "first\nsecond", err.Error())
require.Equal(t, Fields{"key": "val"}, FieldsFromError(err))
}
46 changes: 46 additions & 0 deletions errors/logfields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package errors

import (
"github.com/pkg/errors"

"github.com/Shopify/goose/v2/logger"
)

type LoggableError interface {
error
logger.Loggable
}

func FieldsFromError(err error) Fields {
var loggable LoggableError
if joined, ok := err.(interface{ Unwrap() []error }); ok {
fs := []Fields{}
for _, e := range joined.Unwrap() {
if errors.As(e, &loggable) {
fs = append(fs, Fields(loggable.LogFields()))
}
}
return mergeFields(fs)
}
if errors.As(err, &loggable) {
return Fields(loggable.LogFields())
}

return Fields{}
}

func mergeFieldsCtx(ctx logger.Valuer, err error, fieldsList ...Fields) Fields {
fieldsList = append([]Fields{FieldsFromError(err)}, fieldsList...)
fieldsList = append(fieldsList, Fields(logger.GetLoggableValues(ctx)))
return mergeFields(fieldsList)
}

func mergeFields(fieldsList []Fields) Fields {
fields := Fields{}
for _, fs := range fieldsList {
for k, v := range fs {
fields[k] = v
}
}
return fields
}
76 changes: 76 additions & 0 deletions errors/logfields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package errors

import (
"context"
stderrors "errors"
"testing"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"

"github.com/Shopify/goose/v2/logger"
)

func Test_FieldsFromError_NoFields(t *testing.T) {
require.Empty(t, FieldsFromError(nil))
require.Empty(t, FieldsFromError(New("")))
}

type testError struct {
error
fields logrus.Fields
}

func (e *testError) Unwrap() error {
return e.error
}

func (e *testError) LogFields() logrus.Fields {
return e.fields
}

func Test_FieldsFromError(t *testing.T) {
err1 := &testError{error: New("foo"), fields: logrus.Fields{"KEY1": "VAL1", "KEY2": "VAL2"}}
err2 := Wrap(err1, "bar", Fields{"KEY2": "VAL3", "KEY3": "VAL3"})

// Fields from outer error have precedence
require.Equal(t, Fields{"KEY1": "VAL1", "KEY2": "VAL3", "KEY3": "VAL3"}, FieldsFromError(err2))
}

func Test_FieldsFromError_From_Context(t *testing.T) {
originalErr := Wrap(New(""), "", Fields{"KEY1": "VAL1"})

ctx := context.Background()
ctx = logger.WithFields(ctx, logrus.Fields{"KEY1": "VAL1", "KEY2": "VAL2"})

err := WrapCtx(ctx, originalErr, "", Fields{"KEY2": "EXTRA", "EXTRA": "EXTRA"}) // KEY2 overlap
require.Equal(t, Fields{
"KEY1": "VAL1",
"KEY2": "VAL2", // fields from inner error have precedence.
"EXTRA": "EXTRA",
}, FieldsFromError(err))
}

func Test_FieldsFromJoinedError(t *testing.T) {
err1 := Wrap(New(""), "", Fields{"FOO": "BAR"})
err2 := stderrors.Join(Wrap(err1, "", Fields{"BAZ": "BOO"}), New("second"))

extracted := FieldsFromError(err2)
require.Equal(t, Fields{"FOO": "BAR", "BAZ": "BOO"}, extracted)

err3 := stderrors.Join(Wrap(err1, "", Fields{"BAZ": "BOO"}), Wrap(New(""), "", Fields{"FOO": "BAR"}))

extracted = FieldsFromError(err3)
require.Equal(t, Fields{"FOO": "BAR", "BAZ": "BOO"}, extracted)

err4 := stderrors.Join(Wrap(New(""), "", Fields{"FOO": "BAR"}), Wrap(New(""), "", Fields{"BAZ": "BOO"}))

extracted = FieldsFromError(err4)
require.Equal(t, Fields{"FOO": "BAR", "BAZ": "BOO"}, extracted)

err5 := stderrors.Join(Wrap(New(""), "", Fields{"FRUIT": "BANANA"}), New(""))
err6 := Wrap(stderrors.Join(Wrap(err5, "", Fields{"BAZ": "BOO"}), Wrap(New(""), "", Fields{"FOO": "BAR"})), "", Fields{"JOINED": "YES"})

extracted = FieldsFromError(err6)
require.Equal(t, Fields{"FOO": "BAR", "BAZ": "BOO", "JOINED": "YES", "FRUIT": "BANANA"}, extracted)
}
Loading