add errx
This commit is contained in:
parent
0a9f6491b4
commit
ff30f6c3d6
6 changed files with 302 additions and 1 deletions
59
README.md
59
README.md
|
|
@ -1,3 +1,62 @@
|
||||||
# errx
|
# errx
|
||||||
|
|
||||||
Simple custom error wrapper utility library for my Go projects.
|
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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
6
Taskfile.yml
Normal file
6
Taskfile.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
version: '3'
|
||||||
|
tasks:
|
||||||
|
test:
|
||||||
|
desc: Run tests
|
||||||
|
cmds:
|
||||||
|
- set -o pipefail && go test fmt -json | tparse -all
|
||||||
89
errx.go
Normal file
89
errx.go
Normal file
|
|
@ -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}
|
||||||
|
}
|
||||||
104
errx_test.go
Normal file
104
errx_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
example_test.go
Normal file
40
example_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.juancwu.dev/juancwu/errx
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
Loading…
Add table
Add a link
Reference in a new issue