diff --git a/README.md b/README.md index b602b95..788a1ab 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ # errx -Simple custom error wrapper utility library for my Go projects. \ No newline at end of file +Simple custom error wrapper utility library for my Go projects. + +`errx` records the **operation** where each error happened and chains those +operations together as the error bubbles up. The result is a readable +breadcrumb instead of a runtime stack trace: + +``` +users.Get: user=42: db.Query: connection refused +``` + +## Usage + +Each function declares its op and wraps errors as it returns them: + +```go +import "git.juancwu.dev/juancwu/errx" + +func (s *Store) Get(id int) (*User, error) { + const op = "users.Get" + + row, err := s.db.Query(id) + if err != nil { + return nil, errx.Wrapf(op, err, "user=%d", id) + } + if row == nil { + return nil, errx.New(op, "not found") + } + return row, nil +} +``` + +The four constructors: + +```go +errx.New(op, msg) // fresh error, static msg +errx.Newf(op, format, args...) // fresh error, formatted msg +errx.Wrap(op, err) // wrap, no extra msg (nil-safe) +errx.Wrapf(op, err, format, args...) // wrap with formatted msg (nil-safe) +``` + +`Wrap` and `Wrapf` return `nil` when passed a `nil` error, so you can chain +them without an extra guard: + +```go +return errx.Wrap(op, s.commit()) +``` + +## Interop + +`*errx.Error` implements `Unwrap`, so `errors.Is` and `errors.As` walk the +chain as expected: + +```go +if errors.Is(err, io.EOF) { ... } + +var e *errx.Error +if errors.As(err, &e) { + log.Printf("op=%s", e.Op) +} +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..a34a2fd --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,6 @@ +version: '3' +tasks: + test: + desc: Run tests + cmds: + - set -o pipefail && go test fmt -json | tparse -all diff --git a/errx.go b/errx.go new file mode 100644 index 0000000..0ec7088 --- /dev/null +++ b/errx.go @@ -0,0 +1,89 @@ +// Package errx provides a small error wrapper that records the operation +// where each error occurred, producing a readable chain in place of a +// runtime stack trace. +// +// Each function declares its own operation name and wraps the underlying +// error with that op (and an optional message). The resulting error +// formats top-down as a colon-joined breadcrumb: +// +// users.Get: lookup failed: db.Query: connection refused +// +// errx is fully compatible with errors.Is and errors.As via Unwrap. +// +// Basic usage: +// +// const op = "users.Get" +// row, err := db.Query(...) +// if err != nil { +// return errx.Wrap(op, err) +// } +package errx + +import ( + "fmt" + "strings" +) + +// Error is the concrete error type produced by this package. Op identifies +// the operation that failed; Msg adds optional context; Err is the +// underlying error being wrapped (may be nil). +type Error struct { + // Op is the operation name, conventionally "package.Func" or + // "Receiver.Method". + Op string + // Msg is an optional context message describing what went wrong. + Msg string + // Err is the underlying error being wrapped. May be nil. + Err error +} + +// Error returns the colon-joined chain of op, message, and wrapped error. +// Empty pieces are omitted. +func (e *Error) Error() string { + parts := make([]string, 0, 3) + if e.Op != "" { + parts = append(parts, e.Op) + } + if e.Msg != "" { + parts = append(parts, e.Msg) + } + if e.Err != nil { + parts = append(parts, e.Err.Error()) + } + return strings.Join(parts, ": ") +} + +// Unwrap returns the wrapped error, enabling errors.Is and errors.As. +func (e *Error) Unwrap() error { + return e.Err +} + +// New returns a new error tagged with op and the static message msg. +func New(op, msg string) error { + return &Error{Op: op, Msg: msg} +} + +// Newf returns a new error tagged with op and a message formatted per +// fmt.Sprintf rules. +func Newf(op, format string, args ...any) error { + return &Error{Op: op, Msg: fmt.Sprintf(format, args...)} +} + +// Wrap returns an error tagged with op that wraps err. If err is nil, +// Wrap returns nil so callers can write `return errx.Wrap(op, doThing())` +// without a guard. +func Wrap(op string, err error) error { + if err == nil { + return nil + } + return &Error{Op: op, Err: err} +} + +// Wrapf returns an error tagged with op that wraps err and adds a message +// formatted per fmt.Sprintf rules. If err is nil, Wrapf returns nil. +func Wrapf(op string, err error, format string, args ...any) error { + if err == nil { + return nil + } + return &Error{Op: op, Msg: fmt.Sprintf(format, args...), Err: err} +} diff --git a/errx_test.go b/errx_test.go new file mode 100644 index 0000000..e241a04 --- /dev/null +++ b/errx_test.go @@ -0,0 +1,104 @@ +package errx + +import ( + "errors" + "io" + "testing" +) + +func TestError_Format(t *testing.T) { + plain := errors.New("connection refused") + nested := &Error{Op: "db.Query", Err: plain} + + tests := []struct { + name string + err *Error + want string + }{ + {"op only", &Error{Op: "users.Get"}, "users.Get"}, + {"op+msg", &Error{Op: "users.Get", Msg: "lookup failed"}, "users.Get: lookup failed"}, + {"op+err", &Error{Op: "users.Get", Err: plain}, "users.Get: connection refused"}, + {"op+msg+err", &Error{Op: "users.Get", Msg: "lookup failed", Err: plain}, "users.Get: lookup failed: connection refused"}, + {"nested *Error", &Error{Op: "users.Get", Msg: "lookup failed", Err: nested}, "users.Get: lookup failed: db.Query: connection refused"}, + {"empty fields", &Error{}, ""}, + {"msg only", &Error{Msg: "boom"}, "boom"}, + {"err only", &Error{Err: plain}, "connection refused"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.err.Error(); got != tt.want { + t.Errorf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNew(t *testing.T) { + err := New("users.Get", "boom").(*Error) + if err.Op != "users.Get" || err.Msg != "boom" || err.Err != nil { + t.Errorf("unexpected fields: %+v", err) + } +} + +func TestNewf(t *testing.T) { + err := Newf("users.Get", "user=%d", 42).(*Error) + if err.Op != "users.Get" || err.Msg != "user=42" || err.Err != nil { + t.Errorf("unexpected fields: %+v", err) + } +} + +func TestWrap(t *testing.T) { + if got := Wrap("users.Get", nil); got != nil { + t.Errorf("Wrap(_, nil) = %v, want nil", got) + } + + inner := errors.New("boom") + got := Wrap("users.Get", inner).(*Error) + if got.Op != "users.Get" || got.Msg != "" || got.Err != inner { + t.Errorf("unexpected fields: %+v", got) + } +} + +func TestWrapf(t *testing.T) { + if got := Wrapf("users.Get", nil, "user=%d", 42); got != nil { + t.Errorf("Wrapf(_, nil, ...) = %v, want nil", got) + } + + inner := errors.New("boom") + got := Wrapf("users.Get", inner, "user=%d", 42).(*Error) + if got.Op != "users.Get" || got.Msg != "user=42" || got.Err != inner { + t.Errorf("unexpected fields: %+v", got) + } +} + +var sentinel = errors.New("sentinel") + +func TestErrorsIs(t *testing.T) { + wrapped := Wrap("a.A", Wrap("b.B", Wrap("c.C", sentinel))) + if !errors.Is(wrapped, sentinel) { + t.Errorf("errors.Is did not find sentinel in chain: %v", wrapped) + } + if errors.Is(wrapped, io.EOF) { + t.Errorf("errors.Is matched unrelated sentinel") + } +} + +func TestErrorsAs(t *testing.T) { + wrapped := Wrap("a.A", Wrap("b.B", Wrap("c.C", io.EOF))) + var target *Error + if !errors.As(wrapped, &target) { + t.Fatalf("errors.As did not find *Error in chain") + } + if target.Op != "a.A" { + t.Errorf("errors.As bound %q, want outermost %q", target.Op, "a.A") + } +} + +func TestNestedChainFormat(t *testing.T) { + err := Wrap("a.A", Wrap("b.B", Wrap("c.C", io.EOF))) + want := "a.A: b.B: c.C: EOF" + if got := err.Error(); got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..2c3b3e6 --- /dev/null +++ b/example_test.go @@ -0,0 +1,40 @@ +package errx_test + +import ( + "errors" + "fmt" + "io" + + "git.juancwu.dev/juancwu/errx" +) + +func ExampleNew() { + err := errx.New("users.Get", "user not found") + fmt.Println(err) + // Output: users.Get: user not found +} + +func ExampleWrap() { + const op = "users.Get" + err := errx.Wrap(op, io.EOF) + fmt.Println(err) + // Output: users.Get: EOF +} + +func ExampleWrapf() { + err := errx.Wrapf("users.Get", io.EOF, "user=%d", 42) + fmt.Println(err) + // Output: users.Get: user=42: EOF +} + +func ExampleError_chain() { + fetch := func() error { return errx.Wrap("db.Query", io.EOF) } + get := func() error { return errx.Wrapf("users.Get", fetch(), "user=%d", 42) } + + err := get() + fmt.Println(err) + fmt.Println(errors.Is(err, io.EOF)) + // Output: + // users.Get: user=42: db.Query: EOF + // true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..88d71ae --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.juancwu.dev/juancwu/errx + +go 1.26.2