diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1128db9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: "Test" + +on: + push: + branches: + - "main" + tags: + - "*" + pull_request: + branches: + - "main" + workflow_dispatch: + +concurrency: + group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}" + cancel-in-progress: true + +jobs: + test: + runs-on: "ubuntu-24.04" + + steps: + - uses: "actions/checkout@v4" + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # 6.5.0 + with: + go-version-file: "go.mod" + + - name: "Test" + run: |- + go test \ + -count=1 \ + -race \ + -short \ + -timeout=5m \ + ./... diff --git a/errors/benchmark_test.go b/errors/benchmark_test.go new file mode 100644 index 0000000..959b59c --- /dev/null +++ b/errors/benchmark_test.go @@ -0,0 +1,45 @@ +package errors + +import ( + stderrors "errors" + "fmt" + "testing" +) + +var benchmarkError error + +func BenchmarkNew(b *testing.B) { + for b.Loop() { + benchmarkError = New("failed") + } +} + +func BenchmarkErrorf(b *testing.B) { + cause := stderrors.New("inner") + for b.Loop() { + benchmarkError = Errorf("failed: %w", cause) + } +} + +func BenchmarkWrap(b *testing.B) { + cause := stderrors.New("inner") + for b.Loop() { + benchmarkError = Wrap(cause, "outer") + } +} + +func BenchmarkError(b *testing.B) { + err := Wrap(stderrors.New("inner"), "outer") + b.ResetTimer() + for b.Loop() { + _ = err.Error() + } +} + +func BenchmarkFormatWithStack(b *testing.B) { + err := Wrap(stderrors.New("inner"), "outer") + b.ResetTimer() + for b.Loop() { + _ = fmt.Sprintf("%+v", err) + } +} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..58660ba --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,194 @@ +package errors + +import ( + "context" + stderrors "errors" + "fmt" + "io" + "runtime" + "strconv" +) + +const maxStackDepth = 32 + +// FieldError describes an error associated with a field. +type FieldError struct { + Field string + Description string + Retryable bool +} + +// Error returns the field and its error description. +func (e FieldError) Error() string { + return e.Field + ": " + e.Description +} + +// Retry reports whether the failed field operation may be retried. +func (e FieldError) Retry() bool { + return e.Retryable +} + +type appError struct { + message string + cause error + formatCause bool + depth uint8 + pcs [maxStackDepth]uintptr +} + +var ( + _ error = (*appError)(nil) + _ fmt.Formatter = (*appError)(nil) + _ error = FieldError{} +) + +func (e *appError) Error() string { + if e.cause == nil || !e.formatCause { + return e.message + } + + return e.message + "; caused by: " + e.cause.Error() +} + +func (e *appError) Unwrap() error { + return e.cause +} + +// Format supports %s, %v, %+v, and %q formatting. +func (e *appError) Format(state fmt.State, verb rune) { + switch verb { + case 's', 'v': + if verb == 'v' && state.Flag('+') { + e.formatStack(state) + return + } + _, _ = io.WriteString(state, e.Error()) + case 'q': + _, _ = io.WriteString(state, strconv.Quote(e.Error())) + default: + _, _ = fmt.Fprintf(state, "%%!%c(%T=%s)", verb, e, e.Error()) + } +} + +func (e *appError) formatStack(w io.Writer) { + _, _ = io.WriteString(w, e.message) + _, _ = io.WriteString(w, "\n") + _ = e.writeStack(w) + if e.cause != nil && e.formatCause { + _, _ = fmt.Fprintf(w, "Caused by: %+v", e.cause) + } +} + +func (e *appError) writeStack(w io.Writer) error { + if e.depth == 0 { + return nil + } + + frames := runtime.CallersFrames(e.pcs[:e.depth]) + for { + frame, more := frames.Next() + if _, err := fmt.Fprintf(w, "%s()\n\t%s:%d\n", frame.Function, frame.File, frame.Line); err != nil { + return err + } + if !more { + return nil + } + } +} + +// New returns an error containing message and a stack captured at the call site. +func New(message string) error { + return newAppError(message, nil, false) +} + +// Errorf formats an error and captures a stack at the call site. +// It follows fmt.Errorf semantics, including support for multiple %w operands. +func Errorf(format string, args ...any) error { + err := fmt.Errorf(format, args...) + if wrapsAny(err) { + return newAppError(err.Error(), err, false) + } + return newAppError(err.Error(), nil, false) +} + +func wrapsAny(err error) bool { + switch err.(type) { + case interface{ Unwrap() error }, interface{ Unwrap() []error }: + return true + default: + return false + } +} + +// Wrap returns nil when err is nil. Otherwise, it adds message and a stack. +func Wrap(err error, message string) error { + if err == nil { + return nil + } + + return newAppError(message, err, true) +} + +// Wrapf returns nil when err is nil. Otherwise, it adds a formatted message and a stack. +func Wrapf(err error, format string, args ...any) error { + if err == nil { + return nil + } + + return newAppError(fmt.Sprintf(format, args...), err, true) +} + +func newAppError(message string, cause error, formatCause bool) error { + err := &appError{ + message: message, + cause: cause, + formatCause: formatCause, + } + err.depth = uint8(runtime.Callers(3, err.pcs[:])) + return err +} + +// Is reports whether any error in err's tree matches target. +func Is(err, target error) bool { + return stderrors.Is(err, target) +} + +// As finds the first error in err's tree assignable to target. +func As(err error, target any) bool { + return stderrors.As(err, target) +} + +// AsType finds the first error in err's tree matching E. +func AsType[E error](err error) (E, bool) { + return stderrors.AsType[E](err) +} + +// IsCanceled reports whether err is or wraps context.Canceled. +func IsCanceled(err error) bool { + return stderrors.Is(err, context.Canceled) +} + +// Ignore calls fn and intentionally discards its error. +func Ignore(fn func() error) { + _ = fn() +} + +// IgnoreCtx calls fn with ctx and intentionally discards its error. +func IgnoreCtx(ctx context.Context, fn func(context.Context) error) { + _ = fn(ctx) +} + +// Any returns the first non-nil error, or nil if all errors are nil. +func Any(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} + +// Join returns an error that wraps the non-nil errors in errs. +func Join(errs ...error) error { + return stderrors.Join(errs...) +} diff --git a/errors/errors_test.go b/errors/errors_test.go new file mode 100644 index 0000000..f4cc9cc --- /dev/null +++ b/errors/errors_test.go @@ -0,0 +1,285 @@ +package errors + +import ( + "context" + stderrors "errors" + "fmt" + "io" + "os" + "strings" + "testing" +) + +type customError struct { + message string +} + +func (e customError) Error() string { + return e.message +} + +type errorWriter struct { + err error +} + +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestNew(t *testing.T) { + t.Parallel() + + err := New("failed") + if got, want := err.Error(), "failed"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + + formatted := fmt.Sprintf("%+v", err) + if !strings.Contains(formatted, "TestNew") { + t.Errorf("%%+v did not contain the caller: %q", formatted) + } + if strings.Contains(formatted, "0x0") { + t.Errorf("%%+v contained an invalid frame: %q", formatted) + } +} + +func TestWrap(t *testing.T) { + t.Parallel() + + cause := stderrors.New("inner") + err := Wrap(cause, "outer") + if got, want := err.Error(), "outer; caused by: inner"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + if !stderrors.Is(err, cause) { + t.Error("wrapped error does not match its cause") + } + if got := Wrap(nil, "outer"); got != nil { + t.Errorf("Wrap(nil, ...) = %v, want nil", got) + } +} + +func TestWrapf(t *testing.T) { + t.Parallel() + + cause := stderrors.New("inner") + err := Wrapf(cause, "outer %d", 42) + if got, want := err.Error(), "outer 42; caused by: inner"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + if !stderrors.Is(err, cause) { + t.Error("wrapped error does not match its cause") + } + if got := Wrapf(nil, "outer %d", 42); got != nil { + t.Errorf("Wrapf(nil, ...) = %v, want nil", got) + } +} + +func TestErrorfPreservesWrappedErrors(t *testing.T) { + t.Parallel() + + first := stderrors.New("first") + second := stderrors.New("second") + err := Errorf("failed: %w and %w", first, second) + + if got, want := err.Error(), "failed: first and second"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + for _, target := range []error{first, second} { + if !stderrors.Is(err, target) { + t.Errorf("Errorf result does not match %q", target) + } + } +} + +func TestErrorfWithoutWrappedError(t *testing.T) { + t.Parallel() + + err := Errorf("failed: %d", 42) + if got, want := err.Error(), "failed: 42"; got != want { + t.Fatalf("Error() = %q, want %q", got, want) + } + if got := stderrors.Unwrap(err); got != nil { + t.Fatalf("errors.Unwrap(err) = %v, want nil", got) + } +} + +func TestAppErrorFormat(t *testing.T) { + t.Parallel() + + err := Wrap(stderrors.New("inner"), "outer") + tests := []struct { + name string + format string + want string + wantCaller bool + }{ + {name: "string", format: "%s", want: "outer; caused by: inner"}, + {name: "value", format: "%v", want: "outer; caused by: inner"}, + {name: "quoted", format: "%q", want: `"outer; caused by: inner"`}, + {name: "stack", format: "%+v", want: "Caused by: inner", wantCaller: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := fmt.Sprintf(tt.format, err) + if tt.wantCaller { + if !strings.Contains(got, tt.want) || !strings.Contains(got, "TestAppErrorFormat") { + t.Fatalf("Sprintf(%q, err) = %q, want cause and caller", tt.format, got) + } + return + } + if got != tt.want { + t.Fatalf("Sprintf(%q, err) = %q, want %q", tt.format, got, tt.want) + } + }) + } +} + +func TestAppErrorWriteStackReturnsWriterError(t *testing.T) { + t.Parallel() + + want := stderrors.New("write failed") + err := New("failed").(*appError) + if got := err.writeStack(errorWriter{err: want}); !stderrors.Is(got, want) { + t.Fatalf("writeStack() error = %v, want %v", got, want) + } +} + +func TestIsAsAndAsType(t *testing.T) { + t.Parallel() + + original := customError{message: "custom"} + err := Wrap(original, "outer") + if !Is(err, original) { + t.Error("Is did not find the wrapped error") + } + + var target customError + if !As(err, &target) || target != original { + t.Fatalf("As target = %#v, want %#v", target, original) + } + + typed, ok := AsType[customError](err) + if !ok || typed != original { + t.Fatalf("AsType result = (%#v, %t), want (%#v, true)", typed, ok, original) + } + + var pathError *os.PathError + if As(err, &pathError) { + t.Fatalf("As unexpectedly matched %T", pathError) + } +} + +func TestIsCanceled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + {name: "nil"}, + {name: "canceled", err: context.Canceled, want: true}, + {name: "wrapped canceled", err: fmt.Errorf("operation: %w", context.Canceled), want: true}, + {name: "deadline", err: context.DeadlineExceeded}, + {name: "ordinary", err: stderrors.New("failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsCanceled(tt.err); got != tt.want { + t.Errorf("IsCanceled(%v) = %t, want %t", tt.err, got, tt.want) + } + }) + } +} + +func TestFieldError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err FieldError + want string + }{ + {name: "complete", err: FieldError{Field: "name", Description: "required"}, want: "name: required"}, + {name: "empty field", err: FieldError{Description: "required"}, want: ": required"}, + {name: "empty description", err: FieldError{Field: "name"}, want: "name: "}, + {name: "empty", want: ": "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.err.Error(); got != tt.want { + t.Errorf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFieldErrorRetry(t *testing.T) { + t.Parallel() + + if !(FieldError{Retryable: true}).Retry() { + t.Error("Retry() = false, want true") + } + if (FieldError{}).Retry() { + t.Error("Retry() = true, want false") + } +} + +func TestAny(t *testing.T) { + t.Parallel() + + want := stderrors.New("first") + if got := Any(nil, want, stderrors.New("second")); got != want { + t.Fatalf("Any() = %v, want %v", got, want) + } + if got := Any(nil, nil); got != nil { + t.Fatalf("Any() = %v, want nil", got) + } +} + +func TestIgnore(t *testing.T) { + t.Parallel() + + called := false + Ignore(func() error { + called = true + return io.ErrUnexpectedEOF + }) + if !called { + t.Error("Ignore did not call its function") + } + + ctx := context.WithValue(context.Background(), struct{}{}, "value") + calledWithContext := false + IgnoreCtx(ctx, func(got context.Context) error { + calledWithContext = true + if got != ctx { + t.Errorf("IgnoreCtx context differs from its input") + } + return io.ErrUnexpectedEOF + }) + if !calledWithContext { + t.Error("IgnoreCtx did not call its function") + } +} + +func TestJoin(t *testing.T) { + t.Parallel() + + first := stderrors.New("first") + second := stderrors.New("second") + err := Join(first, nil, second) + if !stderrors.Is(err, first) || !stderrors.Is(err, second) { + t.Fatalf("Join result does not contain both errors: %v", err) + } + if got := Join(); got != nil { + t.Fatalf("Join() = %v, want nil", got) + } +}