From 2dc95f49a6de55265f7cb53d56bbe436ecffd377 Mon Sep 17 00:00:00 2001 From: rs Date: Wed, 20 May 2026 00:13:59 +0100 Subject: [PATCH 1/2] new error wrap --- .gitignore | 17 ++++++++ error_functions.go | 9 ++-- error_serialization.go | 21 ++++++++++ error_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 4365a12..c7edfea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,20 @@ go.work /.idea/errors-go.iml /.idea/modules.xml +/.opencode +/.env.example +/.nvmrc +/specs +/AGENTS.md +/opencode.json +/tui.json +/.utcp_config.json +/CLAUDE.md +/.mcp.json +/.claude +/.codex +/GEMINI.md +/.agents +/.gemini +# CocoIndex Code (ccc) +/.cocoindex_code/ diff --git a/error_functions.go b/error_functions.go index dccc1d0..3f2667a 100644 --- a/error_functions.go +++ b/error_functions.go @@ -160,6 +160,7 @@ func Join(errs ...error) error { func newWithArgs(depth Depth, message string, args ...interface{}) E { var code = UnknownErrorCode + var codeProvided bool var fields []*FieldError var toWrap error @@ -172,6 +173,7 @@ func newWithArgs(depth Depth, message string, args ...interface{}) E { switch v := args[i].(type) { case ErrorCode: code = v + codeProvided = true args = append(args[:i], args[i+1:]...) case FieldError: fields = append(fields, &v) @@ -196,9 +198,10 @@ func newWithArgs(depth Depth, message string, args ...interface{}) E { } if toWrap != nil { - toWrapCasted, ok := As(toWrap) - if ok { - _ = e.WithErrorCode(toWrapCasted.Code) + if !codeProvided { + if toWrapCasted, ok := As(toWrap); ok { + _ = e.WithErrorCode(toWrapCasted.Code) + } } e = e.WithNestedError(toWrap) diff --git a/error_serialization.go b/error_serialization.go index edb82f3..a974833 100644 --- a/error_serialization.go +++ b/error_serialization.go @@ -92,6 +92,27 @@ func (e Error) Unwrap() error { return e.NestedError[0] } +// Is reports whether any error in e's chain has an ErrorCode matching target's. +// It implements the optional errors.Is interface from the standard library, so +// stdlib errors.Is(err, target) will match by ErrorCode across the full nested +// tree (including JoinedErrorCode containers). +// +// Returns false if target is nil or not an *Error. For non-*Error targets the +// standard library continues traversal via Unwrap. +func (e *Error) Is(target error) bool { + if target == nil { + return false + } + + t, ok := As(target) + if !ok { + return false + } + + _, found := Has(e, t.Code, true) + return found +} + func (e Error) MarshalJSON() ([]byte, error) { // Create a custom type for marshaling that won't trigger the MarshalJSON method recursively type AliasError struct { diff --git a/error_test.go b/error_test.go index 5a18152..847c185 100644 --- a/error_test.go +++ b/error_test.go @@ -2,6 +2,7 @@ package errors import ( "encoding/json" + goerrors "errors" "fmt" "github.com/pixie-sh/logger-go/env" "github.com/stretchr/testify/assert" @@ -292,3 +293,96 @@ func TestErrorEdgeCases(t *testing.T) { assert.Equal(t, currentErr.Message, unmarshallCurrentErr.Message) assert.Equal(t, currentErr.NestedError, unmarshallCurrentErr.NestedError) } + +// ErrorCode value layout: NewErrorCode validates that value%1000 is a real +// HTTP status, so the test codes below use 10NNN suffixes mapping to known +// codes (200, 201, 202, ...). This matches the convention in error_join_test.go. + +// TestWrapExplicitCodeOverridesNested verifies AC-1: an explicit ErrorCode +// passed to Wrap is preserved on the resulting *Error, even when the wrapped +// error is itself an *Error with a different code. +func TestWrapExplicitCodeOverridesNested(t *testing.T) { + innerCode := NewErrorCode("INNER_T1", 10200) + outerCode := NewErrorCode("OUTER_T1", 20201) + inner := New("inner", innerCode) + + wrapped := Wrap(inner, "wrapped", outerCode) + + assert.Equal(t, outerCode, wrapped.Code) + assert.Len(t, wrapped.NestedError, 1) + assert.Equal(t, inner, wrapped.NestedError[0]) +} + +// TestWrapInheritsNestedCodeWhenNoExplicit verifies AC-2: when no explicit +// ErrorCode is provided and the wrapped error is an *Error, the nested code +// is inherited (regression guard for prior behavior). +func TestWrapInheritsNestedCodeWhenNoExplicit(t *testing.T) { + innerCode := NewErrorCode("INNER_T2", 10202) + inner := New("inner", innerCode) + + wrapped := Wrap(inner, "wrapped") + + assert.Equal(t, innerCode, wrapped.Code) +} + +// TestWrapFallbackForStdError verifies AC-3: wrapping a non-*Error with no +// explicit ErrorCode falls back to UnknownErrorCode. +func TestWrapFallbackForStdError(t *testing.T) { + stdErr := fmt.Errorf("plain") + + wrapped := Wrap(stdErr, "wrapped") + + assert.Equal(t, UnknownErrorCode, wrapped.Code) + assert.Len(t, wrapped.NestedError, 1) + assert.Equal(t, stdErr, wrapped.NestedError[0]) +} + +// TestErrorIsGoCompat verifies AC-4/AC-5: stdlib errors.Is matches *Error +// values by ErrorCode across single-nested and multi-nested (joined) chains, +// and falls through to Unwrap-based traversal for non-*Error targets. +func TestErrorIsGoCompat(t *testing.T) { + codeA := NewErrorCode("CODE_A", 10203) + codeB := NewErrorCode("CODE_B", 10204) + + sentinel := New("sentinel", codeA) + + // Single-nested wrap with code preservation (inherited via newWithArgs). + wrapped := Wrap(sentinel, "ctx") + assert.True(t, goerrors.Is(wrapped, sentinel)) + + // Explicit override: outer code != sentinel code; sentinel is still found + // via Unwrap traversal of the nested chain. + overridden := Wrap(sentinel, "override", codeB) + assert.True(t, goerrors.Is(overridden, sentinel)) + + // Joined / multi-nested: both branches are reachable via Has traversal + // from inside the custom Is method. + other := New("other", codeB) + joined := Join(sentinel, other) + assert.True(t, goerrors.Is(joined, sentinel)) + assert.True(t, goerrors.Is(joined, other)) + + // Non-*Error target: our Is returns false, stdlib continues via Unwrap() + // and matches by pointer identity. + plain := fmt.Errorf("plain") + wrappedPlain := Wrap(plain, "ctx") + assert.True(t, goerrors.Is(wrappedPlain, plain)) + + // Nil target: stdlib short-circuits without invoking our Is. + assert.False(t, goerrors.Is(wrapped, nil)) + + // Non-matching sentinel: not found anywhere in the chain. + unrelated := New("unrelated", NewErrorCode("UNRELATED", 10205)) + assert.False(t, goerrors.Is(wrapped, unrelated)) +} + +// TestErrorAsGoCompat verifies AC-5: stdlib errors.As resolves an *Error +// target through a single-nested chain (regression guard). +func TestErrorAsGoCompat(t *testing.T) { + inner := New("inner", NewErrorCode("AS_TEST", 10206)) + wrapped := Wrap(inner, "wrapped") + + var got *Error + assert.True(t, goerrors.As(wrapped, &got)) + assert.NotNil(t, got) +} From 614c6b4905a6232446ca30043f114fbb94b7c3a9 Mon Sep 17 00:00:00 2001 From: rs Date: Mon, 25 May 2026 23:56:08 +0100 Subject: [PATCH 2/2] add support for fmt.Errorf %w semantics in error wrapping --- error.go | 19 ++++++++++++ error_functions.go | 28 ++++++++++++----- error_test.go | 75 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/error.go b/error.go index 791475c..d8ba424 100644 --- a/error.go +++ b/error.go @@ -68,6 +68,25 @@ func newWithCallerDepth(depth Depth, code ErrorCode, format string, messages ... } } +// newWithCallerDepthErrorf builds an Error using fmt.Errorf so the %w verb +// in format resolves correctly. The wrap relationship is captured separately +// in NestedError by the caller; this helper only owns message formatting. +func newWithCallerDepthErrorf(depth Depth, code ErrorCode, format string, messages ...interface{}) E { + var st *StackTrace + if env.IsDebugActive() { + st = &StackTrace{ + Trace: debug.Stack(), + CallerPath: caller.NewCaller(depth).String(), + } + } + + return &Error{ + Code: code, + Message: fmt.Errorf(format, messages...).Error(), + Trace: st, + } +} + // GetHTTPStatus get's the http status for the error func (e *Error) GetHTTPStatus() int { return e.Code.HTTPError diff --git a/error_functions.go b/error_functions.go index 3f2667a..3b8b2dc 100644 --- a/error_functions.go +++ b/error_functions.go @@ -162,7 +162,9 @@ func newWithArgs(depth Depth, message string, args ...interface{}) E { var code = UnknownErrorCode var codeProvided bool var fields []*FieldError - var toWrap error + var wrapped []error + + hasW := strings.Contains(message, "%w") for i := 0; i < len(args); { if args[i] == nil { @@ -182,14 +184,24 @@ func newWithArgs(depth Depth, message string, args ...interface{}) E { fields = append(fields, v) args = append(args[:i], args[i+1:]...) case error: - toWrap = v - args = append(args[:i], args[i+1:]...) + wrapped = append(wrapped, v) + if hasW { + i++ + } else { + args = append(args[:i], args[i+1:]...) + } default: i++ } } - e := newWithCallerDepth(depth, code, message, args...) + var e E + if hasW { + e = newWithCallerDepthErrorf(depth, code, message, args...) + } else { + e = newWithCallerDepth(depth, code, message, args...) + } + if len(fields) > 0 { e.FieldErrors = fields if code == UnknownErrorCode { @@ -197,14 +209,14 @@ func newWithArgs(depth Depth, message string, args ...interface{}) E { } } - if toWrap != nil { + if len(wrapped) > 0 { if !codeProvided { - if toWrapCasted, ok := As(toWrap); ok { - _ = e.WithErrorCode(toWrapCasted.Code) + if firstCasted, ok := As(wrapped[0]); ok { + _ = e.WithErrorCode(firstCasted.Code) } } - e = e.WithNestedError(toWrap) + e = e.WithNestedError(wrapped...) } return e diff --git a/error_test.go b/error_test.go index 847c185..8e81d60 100644 --- a/error_test.go +++ b/error_test.go @@ -4,10 +4,11 @@ import ( "encoding/json" goerrors "errors" "fmt" - "github.com/pixie-sh/logger-go/env" - "github.com/stretchr/testify/assert" "os" "testing" + + "github.com/pixie-sh/logger-go/env" + "github.com/stretchr/testify/assert" ) func TestErrors(t *testing.T) { @@ -386,3 +387,73 @@ func TestErrorAsGoCompat(t *testing.T) { assert.True(t, goerrors.As(wrapped, &got)) assert.NotNil(t, got) } + +// TestWrapWVerbRendersInline verifies %w in the format string renders the +// wrapped error's message inline, matching fmt.Errorf semantics. The wrapped +// error is also captured in NestedError and reachable via Unwrap(). +func TestWrapWVerbRendersInline(t *testing.T) { + inner := fmt.Errorf("disk full") + + wrapped := Wrap(inner, "save failed: %w") + + assert.Equal(t, "save failed: disk full", wrapped.Message) + assert.Len(t, wrapped.NestedError, 1) + assert.Equal(t, inner, wrapped.NestedError[0]) + assert.Equal(t, inner, goerrors.Unwrap(wrapped)) + assert.True(t, goerrors.Is(wrapped, inner)) +} + +// TestNewWVerbRendersInline verifies %w works via New as well. +func TestNewWVerbRendersInline(t *testing.T) { + inner := fmt.Errorf("connection refused") + + err := New("dial failed: %w", inner) + + assert.Equal(t, "dial failed: connection refused", err.Message) + assert.Len(t, err.NestedError, 1) + assert.Equal(t, inner, goerrors.Unwrap(err)) +} + +// TestWVerbWithErrorCode verifies %w coexists with explicit ErrorCode args. +func TestWVerbWithErrorCode(t *testing.T) { + code := NewErrorCode("W_VERB_CODE", 10300) + inner := fmt.Errorf("upstream timeout") + + err := New("call failed: %w", inner, code) + + assert.Equal(t, code, err.Code) + assert.Equal(t, "call failed: upstream timeout", err.Message) + assert.Equal(t, inner, goerrors.Unwrap(err)) +} + +// TestMultipleWVerbs verifies multiple %w directives render all referenced +// errors and capture all of them in NestedError, preserving order. +func TestMultipleWVerbs(t *testing.T) { + a := fmt.Errorf("alpha") + b := fmt.Errorf("beta") + + err := New("combined: %w and %w", a, b) + + assert.Equal(t, "combined: alpha and beta", err.Message) + assert.Len(t, err.NestedError, 2) + assert.Equal(t, a, err.NestedError[0]) + assert.Equal(t, b, err.NestedError[1]) + // Unwrap() returns NestedError[0] per the library contract. + assert.Equal(t, a, goerrors.Unwrap(err)) + + jsonb, _ := json.Marshal(err) + fmt.Println(string(jsonb)) +} + +// TestNoWVerbBackwardCompat ensures calls without %w behave identically to +// the pre-change code path. +func TestNoWVerbBackwardCompat(t *testing.T) { + inner := fmt.Errorf("root cause") + + err := Wrap(inner, "operation %s", "failed") + + assert.Equal(t, "operation failed", err.Message) + assert.Len(t, err.NestedError, 1) + assert.Equal(t, inner, err.NestedError[0]) + assert.Equal(t, inner, goerrors.Unwrap(err)) +}