From e87a8a037cc0416847eb616687cd031eef90f363 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 25 Jun 2026 21:28:45 +0800 Subject: [PATCH 1/2] fix(errors): add tag helpers and harden error handling Implement missing GetTags/CollectTags APIs, fix ErrCode.As and registry panics, remove WrapStack stderr side effects, and align errparser with structured errors. Co-authored-by: Cursor --- errors/README.md | 28 +++++++++- errors/README.zh.md | 28 +++++++++- errors/basic_errors_test.go | 92 ++++++++++++++++++++++--------- errors/errcode/errorcodes.go | 36 +++--------- errors/errcode/errorcodes_test.go | 32 +++-------- errors/errcode/registry.go | 2 +- errors/errors.go | 27 +++++---- errors/errparser/parser.go | 14 ++--- errors/errparser/parser_test.go | 48 ++++++++++++++++ errors/simple.go | 4 ++ errors/tags.go | 62 +++++++++++++++++++++ errors/{aaa.go => types.go} | 18 +++++- errors/walk.go | 27 +++++++++ errors/wrap.go | 4 +- errors/z_error_stack_test.go | 16 +++++- 15 files changed, 333 insertions(+), 105 deletions(-) create mode 100644 errors/errparser/parser_test.go create mode 100644 errors/tags.go rename errors/{aaa.go => types.go} (79%) create mode 100644 errors/walk.go diff --git a/errors/README.md b/errors/README.md index 7612f52e..459c03a9 100644 --- a/errors/README.md +++ b/errors/README.md @@ -8,7 +8,21 @@ The errors module provides enhanced error handling capabilities for Go applicati - **Stack Traces**: Automatic stack trace capture for debugging - **Error Wrapping**: Preserve error chains with additional context - **gRPC Compatibility**: Integration with gRPC status codes -- **Panic Recovery**: Safe recovery from panics with context preservation +- **Panic Recovery**: Works with the `recovery` package for safe panic handling + +## Error Message Semantics + +`Wrap` / `Wrapf` add context as metadata rather than changing `Error()` text: + +```go +err := errors.New("db failed") +wrapped := errors.Wrap(err, "init service failed") + +wrapped.Error() // "db failed" — root message preserved +// Context is available via CollectTags(wrapped)["msg"] or wrapped.(*errors.ErrWrap).String() +``` + +Use `String()`, `DebugPrint`, or `MarshalError` when you need the full structured representation. ## Installation @@ -119,11 +133,14 @@ err := errors.New("payment processing failed", errors.Tags{ "currency": "USD", }) -// Access metadata +// Access metadata from the root *Err if tags := errors.GetTags(err); tags != nil { txnID := tags["transaction_id"] // Process transaction ID } + +// Merge tags from every layer in the chain +allTags := errors.CollectTags(err) ``` ### Stack Trace Analysis @@ -199,11 +216,16 @@ log.Error().RawJSON("error", errors.JsonPrint(err)).Send() | `Is(err, target error)` | Check error chain for specific error | | `Unwrap(err error)` | Get underlying error | | `GetErrorId(err error)` | Get unique error identifier | +| `GetTags(err error)` | Get tags from the innermost `*Err` | +| `CollectTags(err error)` | Merge tags from all layers in the chain | +| `RootCause(err error)` | Return the deepest error in the chain | +| `Walk(err error, fn func(error) bool)` | Traverse the error chain | +| `MarshalError(err error)` | Serialize error to JSON with error return | ### Utility Functions | Function | Description | |----------|-------------| | `DebugPrint(err error)` | Pretty print error with stack trace | -| `JsonPrint(err error)` | Serialize error to JSON | +| `JsonPrint(err error)` | Serialize error to JSON (returns nil on failure) | | `ErrJsonify(err error)` | Convert error to structured data | \ No newline at end of file diff --git a/errors/README.zh.md b/errors/README.zh.md index 6a069a0c..84548c09 100644 --- a/errors/README.zh.md +++ b/errors/README.zh.md @@ -8,7 +8,21 @@ - **堆栈跟踪**: 自动堆栈跟踪捕获用于调试 - **错误包装**: 使用元数据保留错误链 - **gRPC兼容性**: 与gRPC状态代码集成 -- **Panic恢复**: 安全的panic恢复机制 +- **Panic恢复**: 与 `recovery` 包配合使用,安全处理 panic + +## 错误消息语义 + +`Wrap` / `Wrapf` 会把上下文作为元数据附加,而不会改变 `Error()` 的文本: + +```go +err := errors.New("数据库连接失败") +wrapped := errors.Wrap(err, "初始化服务失败") + +wrapped.Error() // "数据库连接失败" — 保留根错误消息 +// 上下文可通过 CollectTags(wrapped)["msg"] 或 wrapped.(*errors.ErrWrap).String() 获取 +``` + +需要完整结构化表示时,请使用 `String()`、`DebugPrint` 或 `MarshalError`。 ## 安装 @@ -119,11 +133,14 @@ err := errors.New("支付处理失败", errors.Tags{ "currency": "USD", }) -// 访问元数据 +// 从根 *Err 获取元数据 if tags := errors.GetTags(err); tags != nil { txnID := tags["transaction_id"] // 处理交易ID } + +// 合并整条错误链上的 tags +allTags := errors.CollectTags(err) ``` ### 堆栈跟踪分析 @@ -199,11 +216,16 @@ log.Error().RawJSON("error", errors.JsonPrint(err)).Send() | `Is(err, target error)` | 检查错误链中的特定错误 | | `Unwrap(err error)` | 获取底层错误 | | `GetErrorId(err error)` | 获取唯一错误标识符 | +| `GetTags(err error)` | 获取最内层 `*Err` 上的 tags | +| `CollectTags(err error)` | 合并错误链各层的 tags | +| `RootCause(err error)` | 返回最底层错误 | +| `Walk(err error, fn func(error) bool)` | 遍历错误链 | +| `MarshalError(err error)` | 序列化为 JSON 并返回 error | ### 实用函数 | 函数 | 描述 | |------|------| | `DebugPrint(err error)` | 带堆栈跟踪的漂亮打印错误 | -| `JsonPrint(err error)` | 将错误序列化为JSON | +| `JsonPrint(err error)` | 序列化为 JSON(失败时返回 nil) | | `ErrJsonify(err error)` | 将错误转换为结构化数据 | \ No newline at end of file diff --git a/errors/basic_errors_test.go b/errors/basic_errors_test.go index 34fcc63d..c8b17e44 100644 --- a/errors/basic_errors_test.go +++ b/errors/basic_errors_test.go @@ -7,8 +7,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - - "github.com/pubgo/funk/v2/errors/errparser" ) type customError struct{ msg string } @@ -229,30 +227,6 @@ func TestTags_ToMapString(t *testing.T) { assert.Equal(t, "true", mapString["bool_key"]) } -func TestParse(t *testing.T) { - // 测试nil - var nilErr error - parsedNil := errparser.Parse(nilErr) - assert.Nil(t, parsedNil) - - // 测试error类型 - err := New("test error") - parsedErr := errparser.Parse(err) - assert.Equal(t, err, parsedErr) - - // 测试string类型 - parsedStr := errparser.Parse("string error") - assert.Equal(t, "string error", parsedStr.Error()) - - // 测试[]byte类型 - parsedBytes := errparser.Parse([]byte("bytes error")) - assert.Equal(t, "bytes error", parsedBytes.Error()) - - // 测试其他类型 - parsedOther := errparser.Parse(42) - assert.NotNil(t, parsedOther) -} - func TestIfErr(t *testing.T) { // 测试无错误的情况 result := IfErr(nil, func(err error) error { @@ -349,3 +323,69 @@ func TestWrapKV(t *testing.T) { nilErr := WrapKV(nil, "key", "value") assert.Nil(t, nilErr) } + +func TestGetTags(t *testing.T) { + err := New("payment failed", Tags{"transaction_id": "txn_123"}) + tags := GetTags(err) + assert.Equal(t, "txn_123", tags["transaction_id"]) + + wrapped := WrapTags(err, Tags{"layer": "service"}) + tags = GetTags(wrapped) + assert.Equal(t, "txn_123", tags["transaction_id"]) + + collected := CollectTags(wrapped) + assert.Equal(t, "txn_123", collected["transaction_id"]) + assert.Equal(t, "service", collected["layer"]) +} + +func TestCollectTagsNil(t *testing.T) { + assert.Nil(t, GetTags(nil)) + assert.Nil(t, CollectTags(nil)) +} + +func TestTagsCloneOnWrap(t *testing.T) { + tags := Tags{"key": "value"} + err := WrapTags(New("original"), tags) + tags["key"] = "mutated" + + wrapped, ok := AsA[*ErrWrap](err) + assert.True(t, ok) + assert.Equal(t, "value", (*wrapped).Tags["key"]) +} + +func TestUnwrapStdlib(t *testing.T) { + inner := New("inner") + wrapped := fmt.Errorf("outer: %w", inner) + assert.Equal(t, inner, Unwrap(wrapped)) +} + +func TestMarshalError(t *testing.T) { + data, err := MarshalError(New("marshal test")) + assert.NoError(t, err) + assert.Contains(t, string(data), "marshal test") + + data, err = MarshalError(nil) + assert.NoError(t, err) + assert.Nil(t, data) +} + +func TestRootCauseAndWalk(t *testing.T) { + root := &customError{msg: "root"} + outer := Wrap(Wrap(root, "middle"), "outer") + assert.Equal(t, root, RootCause(outer)) + + var layers int + Walk(outer, func(err error) bool { + layers++ + return true + }) + assert.GreaterOrEqual(t, layers, 3) +} + +func TestWrapStackCapturesStacks(t *testing.T) { + stackErr := WrapStack(New("stack test")) + wrapped, ok := AsA[*ErrWrap](stackErr) + assert.True(t, ok) + assert.NotEmpty(t, (*wrapped).Stacks) + assert.NotEmpty(t, (*wrapped).Caller) +} diff --git a/errors/errcode/errorcodes.go b/errors/errcode/errorcodes.go index 47e451b0..86fb0beb 100644 --- a/errors/errcode/errorcodes.go +++ b/errors/errcode/errorcodes.go @@ -118,24 +118,6 @@ func MustProtoToAny(p proto.Message) *anypb.Any { return pb } -func handleGrpcError(err error) error { - switch v := err.(type) { - case nil: - return nil - case *errors.ErrWrap: - return v - case GRPCStatus: - return NewCodeErr(&errorpb.ErrCode{ - Message: v.GRPCStatus().Message(), - StatusCode: errorpb.Code(v.GRPCStatus().Code()), - Name: "lava.grpc.status", - Details: v.GRPCStatus().Proto().Details, - }) - default: - return err - } -} - // ErrorProto is an interface for errors that can be converted to protobuf messages. type ErrorProto interface { error @@ -176,7 +158,7 @@ func NewCodeErrWithMsg(code *errorpb.ErrCode, msg string, details ...proto.Messa return nil } - code.Message = strings.ToTitle(strings.TrimSpace(msg)) + code.Message = strings.TrimSpace(msg) return NewCodeErr(code, details...) } @@ -264,23 +246,23 @@ func (t *ErrCode) Is(err error) bool { return false } -func (t *ErrCode) As(err any) bool { - if err == nil { +func (t *ErrCode) As(target any) bool { + if target == nil { return false } - if err1, ok := err.(*ErrCode); ok { //nolint - err1.pb = t.pb + if targetErr, ok := target.(**ErrCode); ok { + *targetErr = t return true } - if err1, ok := err.(**errorpb.ErrCode); ok { - *err1 = t.pb + if targetPb, ok := target.(**errorpb.ErrCode); ok { + *targetPb = t.pb return true } - if err1, ok := err.(*errorpb.ErrCode); ok { - *err1 = lo.FromPtr(proto.Clone(t.pb).(*errorpb.ErrCode)) + if targetPb, ok := target.(*errorpb.ErrCode); ok { + *targetPb = lo.FromPtr(proto.Clone(t.pb).(*errorpb.ErrCode)) return true } diff --git a/errors/errcode/errorcodes_test.go b/errors/errcode/errorcodes_test.go index 07223528..2eea0153 100644 --- a/errors/errcode/errorcodes_test.go +++ b/errors/errcode/errorcodes_test.go @@ -2,7 +2,6 @@ package errcode_test import ( "encoding/json" - "errors" "testing" "github.com/samber/lo" @@ -13,6 +12,7 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" + "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/errors/errcode" "github.com/pubgo/funk/v2/proto/errorpb" ) @@ -89,7 +89,7 @@ func TestNewCodeErrWithMsg(t *testing.T) { err := errcode.NewCodeErrWithMsg(code, "Custom message") assert.NotNil(t, err) - assert.Equal(t, "CUSTOM MESSAGE", err.Error()) // 消息会被转为大写 + assert.Equal(t, "Custom message", err.Error()) // 测试nil错误码 nilErr := errcode.NewCodeErrWithMsg(nil, "message") @@ -107,7 +107,7 @@ func TestNewCodeErrWithMsg(t *testing.T) { }, }) assert.NotNil(t, detailsErr) - assert.Equal(t, "CUSTOM MESSAGE WITH DETAILS", detailsErr.Error()) + assert.Equal(t, "Custom message with details", detailsErr.Error()) } func TestWrapCode(t *testing.T) { @@ -236,28 +236,14 @@ func TestErrCode_As(t *testing.T) { err := errcode.NewCodeErr(code) - // 测试转换为*ErrCode - // 注意:As方法的第一个分支永远不会执行,因为类型不匹配 - // var targetErr *errcode.ErrCode - // assert.True(t, err.(*errcode.ErrCode).As(&targetErr)) - // assert.NotNil(t, targetErr) + var targetErr *errcode.ErrCode + assert.True(t, errors.As(err, &targetErr)) + assert.Equal(t, code.Name, targetErr.Proto().(*errorpb.ErrCode).Name) - // 测试转换为*errorpb.ErrCode var targetPb *errorpb.ErrCode - // 注意:As方法的实现可能有问题,所以我们只验证不panic - assert.NotPanics(t, func() { - _ = err.(*errcode.ErrCode).As(&targetPb) - }) - - // 注意:值类型的转换可能不适用于所有情况,所以我们只测试指针类型 - // 测试转换为errorpb.ErrCode(值类型) - // var targetPbValue errorpb.ErrCode - // assert.True(t, err.(*errcode.ErrCode).As(&targetPbValue)) - // 对于值类型,我们检查字段而不是整个结构体 - // assert.Equal(t, code.Name, targetPbValue.Name) - // assert.Equal(t, code.Code, targetPbValue.Code) - // assert.Equal(t, code.Message, targetPbValue.Message) - _ = targetPb // 避免未使用变量错误 + assert.True(t, errors.As(err, &targetPb)) + assert.Equal(t, code.Name, targetPb.Name) + assert.Equal(t, code.Code, targetPb.Code) } func TestErrCode_MarshalJSON(t *testing.T) { diff --git a/errors/errcode/registry.go b/errors/errcode/registry.go index 5e1b9e4b..8c6c462f 100644 --- a/errors/errcode/registry.go +++ b/errors/errcode/registry.go @@ -20,7 +20,7 @@ func GetErrCodes() []*errorpb.ErrCode { // RegisterErrCodes 注册错误码 func RegisterErrCodes(code *errorpb.ErrCode) error { if errorCodes[code.Name] != nil { - panic(fmt.Sprintf("code exists, code=%s", errorCodes[code.Name])) + panic(fmt.Sprintf("error code already registered: name=%q", code.Name)) } errorCodes[code.Name] = code diff --git a/errors/errors.go b/errors/errors.go index 943a07bb..0998203e 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -9,8 +9,6 @@ import ( "reflect" "github.com/samber/lo" - - "github.com/pubgo/funk/v2/stack" ) func IfErr(err error, fn func(err error) error) error { @@ -22,7 +20,7 @@ func IfErr(err error, fn func(err error) error) error { } func New(msg string, tags ...Tags) error { - return WrapCaller(newSimpleErr(&Err{Msg: msg, id: NewErrorId(), Tags: lo.FirstOrEmpty(tags)}), 1) + return WrapCaller(newSimpleErr(&Err{Msg: msg, id: NewErrorId(), Tags: cloneTags(lo.FirstOrEmpty(tags))}), 1) } func Errorf(msg string, args ...any) error { @@ -64,11 +62,15 @@ func As(err error, target any) bool { } func Unwrap(err error) error { - u, ok := err.(ErrUnwrapper) - if !ok { + if err == nil { return nil } - return u.Unwrap() + + if u, ok := err.(ErrUnwrapper); ok { + return u.Unwrap() + } + + return errors.Unwrap(err) } func WrapStack(err error) error { @@ -76,7 +78,6 @@ func WrapStack(err error) error { return nil } - stack.Print() return newErrWrapStack(err, Tags{"msg": err.Error()}) } @@ -137,15 +138,19 @@ func WrapKV(err error, key string, value any) error { return newErrWrap(err, Tags{key: value}) } -func JsonPrint(err error) []byte { +func MarshalError(err error) ([]byte, error) { if err == nil { - return nil + return nil, nil } - data, err := json.Marshal(err) + return json.Marshal(err) +} + +func JsonPrint(err error) []byte { + data, err := MarshalError(err) if err != nil { slog.Error("failed to marshal error", "err", err) - panic(fmt.Errorf("failed to marshal error, err=%w", err)) + return nil } return data } diff --git a/errors/errparser/parser.go b/errors/errparser/parser.go index ff9b5bb5..9a76a3e0 100644 --- a/errors/errparser/parser.go +++ b/errors/errparser/parser.go @@ -1,11 +1,11 @@ package errparser import ( - "errors" "fmt" "log/slog" "github.com/pubgo/funk/v2" + "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/stack" ) @@ -16,14 +16,14 @@ type ErrParser = func(val any) (error, bool) var errParserRegistry = make(map[string]ErrParser) func RegisterParser(errParser ErrParser) bool { - key := stack.CallerWithFunc(errParser).String() - if errParserRegistry[key] != nil { - slog.Error("errParser already exists", "parser", errParserRegistry[key]) + if errParser == nil { + slog.Error("errParser is nil") return false } - if errParser == nil { - slog.Error("errParser is nil") + key := stack.CallerWithFunc(errParser).String() + if errParserRegistry[key] != nil { + slog.Error("errParser already exists", "parser", errParserRegistry[key]) return false } @@ -53,6 +53,6 @@ func parseError(val any) error { } } - return fmt.Errorf("%#v", val) + return fmt.Errorf("%#v", v) } } diff --git a/errors/errparser/parser_test.go b/errors/errparser/parser_test.go new file mode 100644 index 00000000..25d05f34 --- /dev/null +++ b/errors/errparser/parser_test.go @@ -0,0 +1,48 @@ +package errparser_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pubgo/funk/v2/errors" + "github.com/pubgo/funk/v2/errors/errparser" +) + +type customValue struct { + msg string +} + +func TestParse(t *testing.T) { + assert.Nil(t, errparser.Parse(nil)) + + orig := errors.New("wrapped") + assert.Equal(t, orig, errparser.Parse(orig)) + + strErr := errparser.Parse("string error") + assert.Equal(t, "string error", strErr.Error()) + _, ok := errors.AsA[*errors.ErrWrap](strErr) + assert.True(t, ok) + + bytesErr := errparser.Parse([]byte("bytes error")) + assert.Equal(t, "bytes error", bytesErr.Error()) + + otherErr := errparser.Parse(42) + assert.Equal(t, "42", otherErr.Error()) +} + +func TestRegisterParser(t *testing.T) { + custom := func(val any) (error, bool) { + if v, ok := val.(customValue); ok { + return errors.New("parsed " + v.msg), true + } + return nil, false + } + + assert.True(t, errparser.RegisterParser(custom)) + assert.False(t, errparser.RegisterParser(custom)) + assert.False(t, errparser.RegisterParser(nil)) + + parsed := errparser.Parse(customValue{msg: "custom"}) + assert.Equal(t, "parsed custom", parsed.Error()) +} diff --git a/errors/simple.go b/errors/simple.go index 406e1076..09154709 100644 --- a/errors/simple.go +++ b/errors/simple.go @@ -34,6 +34,10 @@ func newSimpleErr(err *Err) *Err { err.id = xid.New().String() } + if len(err.Tags) > 0 { + err.Tags = cloneTags(err.Tags) + } + return err } diff --git a/errors/tags.go b/errors/tags.go new file mode 100644 index 00000000..91a735bb --- /dev/null +++ b/errors/tags.go @@ -0,0 +1,62 @@ +package errors + +func layerTags(err error) Tags { + switch e := err.(type) { + case *ErrWrap: + return e.Tags + case *Err: + return e.Tags + case Err: + return e.Tags + default: + return nil + } +} + +// GetTags returns tags from the innermost *Err in the chain. +// When no *Err carries tags, it falls back to the outermost layer tags. +func GetTags(err error) Tags { + if err == nil { + return nil + } + + var inner Tags + for current := err; current != nil; current = Unwrap(current) { + if e, ok := current.(*Err); ok && len(e.Tags) > 0 { + inner = e.Tags + } + } + + if len(inner) > 0 { + return cloneTags(inner) + } + + if tags := layerTags(err); len(tags) > 0 { + return cloneTags(tags) + } + + return nil +} + +// CollectTags merges tags from every *Err and *ErrWrap layer in the chain. +// Outer layers win when keys conflict. +func CollectTags(err error) Tags { + if err == nil { + return nil + } + + collected := make(Tags) + for current := err; current != nil; current = Unwrap(current) { + tags := layerTags(current) + for key, value := range tags { + if _, exists := collected[key]; !exists { + collected[key] = value + } + } + } + + if len(collected) == 0 { + return nil + } + return collected +} diff --git a/errors/aaa.go b/errors/types.go similarity index 79% rename from errors/aaa.go rename to errors/types.go index 729bed2b..d3761bb7 100644 --- a/errors/aaa.go +++ b/errors/types.go @@ -27,6 +27,20 @@ type ErrAs interface { As(any) bool } +type Tags map[string]any + +func cloneTags(tags Tags) Tags { + if len(tags) == 0 { + return nil + } + + out := make(Tags, len(tags)) + for key, value := range tags { + out[key] = value + } + return out +} + func mergeTags(tags ...Tags) Tags { tagList := make(Tags) for _, t := range tags { @@ -37,7 +51,9 @@ func mergeTags(tags ...Tags) Tags { return tagList } -type Tags map[string]any +func (t Tags) Clone() Tags { + return cloneTags(t) +} func (t Tags) Merge(tags Tags) Tags { tagList := make(Tags, len(t)+len(tags)) diff --git a/errors/walk.go b/errors/walk.go new file mode 100644 index 00000000..b2bf8660 --- /dev/null +++ b/errors/walk.go @@ -0,0 +1,27 @@ +package errors + +// RootCause returns the deepest error in the unwrap chain. +func RootCause(err error) error { + if err == nil { + return nil + } + + for { + next := Unwrap(err) + if next == nil { + return err + } + err = next + } +} + +// Walk traverses the error chain from outer to inner. +// Returning false from fn stops traversal. +func Walk(err error, fn func(error) bool) { + for err != nil { + if !fn(err) { + return + } + err = Unwrap(err) + } +} diff --git a/errors/wrap.go b/errors/wrap.go index 0548f555..2adc9f49 100644 --- a/errors/wrap.go +++ b/errors/wrap.go @@ -25,7 +25,7 @@ func newErrWrapStack(err error, tags Tags) *ErrWrap { Caller: stack.Caller(2).String(), Stacks: lo.Map(getStack(), func(item *stack.Frame, index int) string { return item.String() }), Err: err, - Tags: tags, + Tags: cloneTags(tags), errId: GetErrorId(err), } } @@ -38,7 +38,7 @@ func newErrWrap(err error, tags Tags, callers ...int) *ErrWrap { return &ErrWrap{ Caller: stack.Caller(2 + lo.FirstOrEmpty(callers)).String(), Err: err, - Tags: tags, + Tags: cloneTags(tags), errId: GetErrorId(err), } } diff --git a/errors/z_error_stack_test.go b/errors/z_error_stack_test.go index 052d998f..b4b2cb43 100644 --- a/errors/z_error_stack_test.go +++ b/errors/z_error_stack_test.go @@ -3,6 +3,8 @@ package errors_test import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/pubgo/funk/v2/errors" "github.com/pubgo/funk/v2/stack" ) @@ -19,5 +21,17 @@ func TestStack(t *testing.T) { err = errors.WrapStack(err) err = errors.Wrapf(err, "next error name=%s", "wrapf") - errors.DebugPrint(err) + + wrapped, ok := errors.AsA[*errors.ErrWrap](err) + assert.True(t, ok) + + var stacks []string + errors.Walk(err, func(current error) bool { + if wrap, ok := current.(*errors.ErrWrap); ok && len(wrap.Stacks) > 0 { + stacks = wrap.Stacks + } + return true + }) + assert.NotEmpty(t, stacks) + assert.Contains(t, (*wrapped).String(), "hello error") } From f75b88c74c049c71149f81f3b8c56b3701beb201 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 25 Jun 2026 22:32:22 +0800 Subject: [PATCH 2/2] feat(errors): improve ergonomics and expand integration docs Add FormatChain, CollectUserTags, and errcode lookup APIs; align JSON serialization and wire error chains into log and result helpers. Co-authored-by: Cursor --- errors/README.md | 63 +++++++++++++++++++++----- errors/README.zh.md | 63 +++++++++++++++++++++----- errors/_doc.go | 21 +++------ errors/basic_errors_test.go | 53 ++++++++++++++++++++++ errors/errcode/README.md | 66 +++++++++++++++++++++++++++ errors/errcode/errorcodes_test.go | 26 +++++++---- errors/errcode/registry.go | 44 +++++++++++++++--- errors/format.go | 52 ++++++++++++++++++++++ errors/tags.go | 21 +++++++++ errors/util.go | 74 +++++++++++++++++++++++++++---- errors/wrap.go | 13 +----- internal/example/errors/main.go | 66 +++++++++++++++++++-------- log/README.md | 1 + log/impl.log.go | 8 ++++ result/error.go | 18 +++++++- result/z_error_methods_test.go | 10 +++++ 16 files changed, 507 insertions(+), 92 deletions(-) create mode 100644 errors/errcode/README.md create mode 100644 errors/format.go diff --git a/errors/README.md b/errors/README.md index 459c03a9..a32e7ea2 100644 --- a/errors/README.md +++ b/errors/README.md @@ -22,7 +22,7 @@ wrapped.Error() // "db failed" — root message preserved // Context is available via CollectTags(wrapped)["msg"] or wrapped.(*errors.ErrWrap).String() ``` -Use `String()`, `DebugPrint`, or `MarshalError` when you need the full structured representation. +Use `String()`, `DebugPrint`, `MarshalError`, or `FormatChain` when you need the full structured representation. ## Installation @@ -141,6 +141,17 @@ if tags := errors.GetTags(err); tags != nil { // Merge tags from every layer in the chain allTags := errors.CollectTags(err) + +// User tags exclude automatic wrap context under TagKeyMessage ("msg") +userTags := errors.CollectUserTags(err) +``` + +### Readable Error Chains + +```go +wrapped := errors.Wrap(errors.New("db failed"), "init service") +fmt.Println(wrapped.Error()) // db failed +fmt.Println(errors.FormatChain(wrapped)) // init service: db failed ``` ### Stack Trace Analysis @@ -160,30 +171,57 @@ data := errors.ErrJsonify(err) ### With gRPC +See [errcode/README.md](./errcode/README.md) for registry and lookup helpers. + ```go import "github.com/pubgo/funk/v2/errors/errcode" -// Convert to gRPC status -status := errcode.ConvertErr2Status(err) +errcode.MustRegisterErrCode(&errorpb.ErrCode{ + Name: "demo.user.not_found", + Code: 404, + StatusCode: errorpb.Code_NotFound, + Message: "user not found", +}) -// Create error code -code := &errorpb.ErrCode{ - Code: 500, - StatusCode: errorpb.Code_Internal, - Name: "INTERNAL_ERROR", - Message: "Internal server error", +code, ok := errcode.LookupErrCode("demo.user.not_found") +if ok { + err := errcode.NewCodeErr(code) + _ = err } + +status := errcode.ConvertErr2Status(errcode.ParseError(err)) ``` ### With Logging +`log.Err(err)` automatically attaches: + +- `error_id` +- `error_chain` via `FormatChain` +- `error_tags` via `CollectUserTags` +- `error_detail` as JSON + ```go import "github.com/pubgo/funk/v2/log" -// Log errors with context log.Error().Err(err).Msg("operation failed") +``` + +### With result -// Structured error logging +```go +import "github.com/pubgo/funk/v2/result" + +r := result.ErrOf(err) +if r.IsErr() { + _ = r.Message() // full chain + _ = r.Tags() // user tags +} +``` + +### Manual Structured Logging + +```go log.Error().RawJSON("error", errors.JsonPrint(err)).Send() ``` @@ -218,8 +256,11 @@ log.Error().RawJSON("error", errors.JsonPrint(err)).Send() | `GetErrorId(err error)` | Get unique error identifier | | `GetTags(err error)` | Get tags from the innermost `*Err` | | `CollectTags(err error)` | Merge tags from all layers in the chain | +| `CollectUserTags(err error)` | Merge user tags and omit wrap `msg` context | | `RootCause(err error)` | Return the deepest error in the chain | | `Walk(err error, fn func(error) bool)` | Traverse the error chain | +| `FormatChain(err error)` | Join error chain into a single message | +| `FullMessage(err error)` | Alias for `FormatChain` | | `MarshalError(err error)` | Serialize error to JSON with error return | ### Utility Functions diff --git a/errors/README.zh.md b/errors/README.zh.md index 84548c09..25ad2a75 100644 --- a/errors/README.zh.md +++ b/errors/README.zh.md @@ -22,7 +22,7 @@ wrapped.Error() // "数据库连接失败" — 保留根错误消息 // 上下文可通过 CollectTags(wrapped)["msg"] 或 wrapped.(*errors.ErrWrap).String() 获取 ``` -需要完整结构化表示时,请使用 `String()`、`DebugPrint` 或 `MarshalError`。 +需要完整结构化表示时,请使用 `String()`、`DebugPrint`、`MarshalError` 或 `FormatChain`。 ## 安装 @@ -141,6 +141,17 @@ if tags := errors.GetTags(err); tags != nil { // 合并整条错误链上的 tags allTags := errors.CollectTags(err) + +// 用户 tags 会排除自动包装上下文 TagKeyMessage ("msg") +userTags := errors.CollectUserTags(err) +``` + +### 可读错误链 + +```go +wrapped := errors.Wrap(errors.New("数据库连接失败"), "初始化服务") +fmt.Println(wrapped.Error()) // 数据库连接失败 +fmt.Println(errors.FormatChain(wrapped)) // 初始化服务: 数据库连接失败 ``` ### 堆栈跟踪分析 @@ -160,30 +171,57 @@ data := errors.ErrJsonify(err) ### 与gRPC +详见 [errcode/README.md](./errcode/README.md)。 + ```go import "github.com/pubgo/funk/v2/errors/errcode" -// 转换为gRPC状态 -status := errcode.ConvertErr2Status(err) +errcode.MustRegisterErrCode(&errorpb.ErrCode{ + Name: "demo.user.not_found", + Code: 404, + StatusCode: errorpb.Code_NotFound, + Message: "用户不存在", +}) -// 创建错误代码 -code := &errorpb.ErrCode{ - Code: 500, - StatusCode: errorpb.Code_Internal, - Name: "INTERNAL_ERROR", - Message: "内部服务器错误", +code, ok := errcode.LookupErrCode("demo.user.not_found") +if ok { + err := errcode.NewCodeErr(code) + _ = err } + +status := errcode.ConvertErr2Status(errcode.ParseError(err)) ``` ### 与日志记录 +`log.Err(err)` 会自动附加: + +- `error_id` +- `error_chain`(`FormatChain`) +- `error_tags`(`CollectUserTags`) +- `error_detail`(JSON) + ```go import "github.com/pubgo/funk/v2/log" -// 记录带上下文的错误 log.Error().Err(err).Msg("操作失败") +``` + +### 与 result -// 结构化错误日志记录 +```go +import "github.com/pubgo/funk/v2/result" + +r := result.ErrOf(err) +if r.IsErr() { + _ = r.Message() // 完整错误链 + _ = r.Tags() // 用户 tags +} +``` + +### 手动结构化日志 + +```go log.Error().RawJSON("error", errors.JsonPrint(err)).Send() ``` @@ -218,8 +256,11 @@ log.Error().RawJSON("error", errors.JsonPrint(err)).Send() | `GetErrorId(err error)` | 获取唯一错误标识符 | | `GetTags(err error)` | 获取最内层 `*Err` 上的 tags | | `CollectTags(err error)` | 合并错误链各层的 tags | +| `CollectUserTags(err error)` | 合并用户 tags,并排除包装层 `msg` | | `RootCause(err error)` | 返回最底层错误 | | `Walk(err error, fn func(error) bool)` | 遍历错误链 | +| `FormatChain(err error)` | 将错误链拼接为单条消息 | +| `FullMessage(err error)` | `FormatChain` 的别名 | | `MarshalError(err error)` | 序列化为 JSON 并返回 error | ### 实用函数 diff --git a/errors/_doc.go b/errors/_doc.go index 2400e4b9..f26aa6b0 100644 --- a/errors/_doc.go +++ b/errors/_doc.go @@ -1,17 +1,6 @@ +// Package errors provides structured error creation, wrapping, metadata tags, +// stack capture, and JSON-friendly inspection helpers for Go applications. +// +// Wrap and Wrapf preserve the root Error() text and attach context as metadata. +// Use FormatChain or FullMessage when a joined message is needed for logs. package errors - -// https://github.com/pkg/errors -// https://github.com/go-playground/errors -// https://pkg.go.dev/golang.org/x/xerrors -// https://github.com/getlantern/errors/blob/master/errors.go -// https://github.com/go-kratos/kratos/tree/main/errors -// https://github.com/Southclaws/fault -// https://github.com/rotisserie/eris -// https://github.com/cockroachdb/logtags -// https://github.com/cockroachdb/errors/tree/master/contexttags -// https://github.com/cockroachdb/errors -// https://github.com/mschneider82/problem -// https://github.com/rotisserie/eris -// https://github.com/cockroachdb/errors/tree/master/contexttags -// https://github.com/cockroachdb/logtags -// https://github.com/Southclaws/fault diff --git a/errors/basic_errors_test.go b/errors/basic_errors_test.go index c8b17e44..df0d3dc1 100644 --- a/errors/basic_errors_test.go +++ b/errors/basic_errors_test.go @@ -343,6 +343,17 @@ func TestCollectTagsNil(t *testing.T) { assert.Nil(t, CollectTags(nil)) } +func TestCollectUserTags(t *testing.T) { + err := Wrap(New("root", Tags{"code": 1}), "service") + tags := CollectUserTags(err) + _, hasMsg := tags[TagKeyMessage] + assert.False(t, hasMsg) + assert.Equal(t, 1, tags["code"]) + + errOnlyMsg := Wrap(New("only message"), "service") + assert.Nil(t, CollectUserTags(errOnlyMsg)) +} + func TestTagsCloneOnWrap(t *testing.T) { tags := Tags{"key": "value"} err := WrapTags(New("original"), tags) @@ -389,3 +400,45 @@ func TestWrapStackCapturesStacks(t *testing.T) { assert.NotEmpty(t, (*wrapped).Stacks) assert.NotEmpty(t, (*wrapped).Caller) } + +func TestFormatChain(t *testing.T) { + assert.Empty(t, FormatChain(nil)) + + root := New("db failed") + assert.Equal(t, "db failed", FormatChain(root)) + assert.Equal(t, FormatChain(root), FullMessage(root)) + + wrapped := Wrap(Wrap(root, "init service"), "request failed") + assert.Equal(t, "request failed: init service: db failed", FormatChain(wrapped)) +} + +func TestErrJsonifyChain(t *testing.T) { + err := Wrap(Wrap(New("root", Tags{"code": 1}), "middle"), "outer") + data := ErrJsonify(err) + + var messages []string + for current := data; current != nil; current, _ = current["cause"].(map[string]any) { + if fields, ok := current["fields"].(Tags); ok { + if msg, ok := fields["msg"]; ok { + messages = append(messages, fmt.Sprint(msg)) + } + } + if msg, ok := current["err_msg"].(string); ok { + messages = append(messages, msg) + } + } + + assert.Contains(t, messages, "outer") + assert.Contains(t, messages, "middle") + assert.Contains(t, messages, "root") + + var rootLayer map[string]any + for current := data; current != nil; current, _ = current["cause"].(map[string]any) { + if msg, ok := current["err_msg"].(string); ok && msg == "root" { + rootLayer = current + break + } + } + assert.NotNil(t, rootLayer) + assert.Equal(t, 1, rootLayer["tags"].(Tags)["code"]) +} diff --git a/errors/errcode/README.md b/errors/errcode/README.md new file mode 100644 index 00000000..4e119f35 --- /dev/null +++ b/errors/errcode/README.md @@ -0,0 +1,66 @@ +# errcode + +`errcode` adds typed business error codes with protobuf and gRPC compatibility on top of the core `errors` package. + +## Quick Start + +```go +import ( + "github.com/pubgo/funk/v2/errors/errcode" + "github.com/pubgo/funk/v2/proto/errorpb" +) + +const notFound = "demo.user.not_found" + +func init() { + errcode.MustRegisterErrCode(&errorpb.ErrCode{ + Code: 404, + Message: "user not found", + Name: notFound, + StatusCode: errorpb.Code_NotFound, + }) +} + +func loadUser() error { + code, ok := errcode.LookupErrCode(notFound) + if !ok { + return errcode.NewCodeErr(&errorpb.ErrCode{Name: "demo.internal", Message: "missing code"}) + } + return errcode.NewCodeErr(code) +} +``` + +## Registry API + +| Function | Description | +|----------|-------------| +| `RegisterErrCode(code)` | Register and return an error on duplicate names | +| `MustRegisterErrCode(code)` | Register and panic on failure | +| `LookupErrCode(name)` | Clone a registered code by name | +| `GetErrCodes()` | Return all registered codes | +| `RegisterErrCodes(code)` | Deprecated alias of `MustRegisterErrCode` | + +Generated protobuf plugins typically call `RegisterErrCodes` during package init. + +## Error Construction + +| Function | Description | +|----------|-------------| +| `NewCodeErr(code, details...)` | Create a typed code error | +| `NewCodeErrWithMsg(code, msg, details...)` | Override the message | +| `WrapCode(err, code)` | Attach a code to an existing error | +| `ParseError(err)` | Extract `*errorpb.ErrCode` from any error | + +## Protocol Mapping + +| Function | Description | +|----------|-------------| +| `ConvertErr2Status(code)` | Build gRPC status from protobuf code | +| `Err2GrpcCode(err)` | Map standard errors to gRPC codes | +| `GrpcCodeToHTTP(code)` | Map gRPC codes to HTTP status | +| `Http2GrpcCode(status)` | Map HTTP status to gRPC codes | + +## See Also + +- [errors README](../README.md) +- [errorpb definitions](../../proto/errorpb/) diff --git a/errors/errcode/errorcodes_test.go b/errors/errcode/errorcodes_test.go index 2eea0153..6317167e 100644 --- a/errors/errcode/errorcodes_test.go +++ b/errors/errcode/errorcodes_test.go @@ -429,7 +429,6 @@ func TestCloneAndCheck(t *testing.T) { } func TestRegistry(t *testing.T) { - // 测试注册错误码 code := &errorpb.ErrCode{ Code: 404, StatusCode: errorpb.Code_NotFound, @@ -437,17 +436,28 @@ func TestRegistry(t *testing.T) { Message: "Resource not found", } - // 注册错误码 - assert.NotPanics(t, func() { - lo.Must0(errcode.RegisterErrCodes(code)) - }) + assert.NoError(t, errcode.RegisterErrCode(code)) + + lookup, ok := errcode.LookupErrCode(code.Name) + assert.True(t, ok) + assert.Equal(t, code.Name, lookup.Name) + assert.Equal(t, code.Message, lookup.Message) - // 获取所有错误码 codes := errcode.GetErrCodes() assert.NotEmpty(t, codes) - // 尝试重复注册同一个名称的错误码应该panic + assert.Error(t, errcode.RegisterErrCode(code)) assert.Panics(t, func() { - lo.Must0(errcode.RegisterErrCodes(code)) + errcode.MustRegisterErrCode(code) + }) + + // generated code path still works + assert.NotPanics(t, func() { + lo.Must0(errcode.RegisterErrCodes(&errorpb.ErrCode{ + Code: 500, + StatusCode: errorpb.Code_Internal, + Name: "REGISTRY_LEGACY_PATH", + Message: "legacy register", + })) }) } diff --git a/errors/errcode/registry.go b/errors/errcode/registry.go index 8c6c462f..38ad4005 100644 --- a/errors/errcode/registry.go +++ b/errors/errcode/registry.go @@ -4,25 +4,57 @@ import ( "fmt" "github.com/pubgo/funk/v2/proto/errorpb" + "google.golang.org/protobuf/proto" ) var errorCodes = make(map[string]*errorpb.ErrCode) -// GetErrCodes 获取所有错误码 +// GetErrCodes returns all registered error codes. func GetErrCodes() []*errorpb.ErrCode { - var codeList = make([]*errorpb.ErrCode, 0, len(errorCodes)) + codeList := make([]*errorpb.ErrCode, 0, len(errorCodes)) for _, v := range errorCodes { codeList = append(codeList, v) } return codeList } -// RegisterErrCodes 注册错误码 -func RegisterErrCodes(code *errorpb.ErrCode) error { +// LookupErrCode returns a registered error code by name. +func LookupErrCode(name string) (*errorpb.ErrCode, bool) { + code, ok := errorCodes[name] + if !ok { + return nil, false + } + return proto.Clone(code).(*errorpb.ErrCode), true +} + +// RegisterErrCode registers an error code and returns an error when the name already exists. +func RegisterErrCode(code *errorpb.ErrCode) error { + if code == nil { + return fmt.Errorf("errcode: code is nil") + } + if code.Name == "" { + return fmt.Errorf("errcode: code name is empty") + } if errorCodes[code.Name] != nil { - panic(fmt.Sprintf("error code already registered: name=%q", code.Name)) + return fmt.Errorf("errcode: already registered: name=%q", code.Name) + } + + errorCodes[code.Name] = proto.Clone(code).(*errorpb.ErrCode) + return nil +} + +// MustRegisterErrCode registers an error code and panics on failure. +func MustRegisterErrCode(code *errorpb.ErrCode) { + if err := RegisterErrCode(code); err != nil { + panic(err) } +} - errorCodes[code.Name] = code +// RegisterErrCodes registers an error code and panics when the name already exists. +// Deprecated: use RegisterErrCode or MustRegisterErrCode. +func RegisterErrCodes(code *errorpb.ErrCode) error { + if err := RegisterErrCode(code); err != nil { + panic(err) + } return nil } diff --git a/errors/format.go b/errors/format.go new file mode 100644 index 00000000..efeaae89 --- /dev/null +++ b/errors/format.go @@ -0,0 +1,52 @@ +package errors + +import ( + "fmt" + "strings" +) + +func layerMessage(err error) string { + switch e := err.(type) { + case *ErrWrap: + if msg, ok := e.Tags["msg"]; ok { + return strings.TrimSpace(fmt.Sprint(msg)) + } + case *Err: + return strings.TrimSpace(e.Msg) + case Err: + return strings.TrimSpace(e.Msg) + } + + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +// FormatChain returns a human-readable error chain joined with ": ". +// Wrap context from ErrWrap tags is included while duplicate adjacent messages are omitted. +func FormatChain(err error) string { + if err == nil { + return "" + } + + parts := make([]string, 0, 4) + Walk(err, func(current error) bool { + msg := layerMessage(current) + if msg == "" { + return true + } + + if len(parts) == 0 || parts[len(parts)-1] != msg { + parts = append(parts, msg) + } + return true + }) + + return strings.Join(parts, ": ") +} + +// FullMessage is an alias for FormatChain. +func FullMessage(err error) string { + return FormatChain(err) +} diff --git a/errors/tags.go b/errors/tags.go index 91a735bb..ad07e653 100644 --- a/errors/tags.go +++ b/errors/tags.go @@ -1,5 +1,10 @@ package errors +const ( + // TagKeyMessage is the wrap-layer context key populated by Wrap and New. + TagKeyMessage = "msg" +) + func layerTags(err error) Tags { switch e := err.(type) { case *ErrWrap: @@ -60,3 +65,19 @@ func CollectTags(err error) Tags { } return collected } + +// CollectUserTags returns merged user tags from the error chain. +// System wrap context stored under TagKeyMessage is excluded. +func CollectUserTags(err error) Tags { + tags := CollectTags(err) + if len(tags) == 0 { + return nil + } + + userTags := cloneTags(tags) + delete(userTags, TagKeyMessage) + if len(userTags) == 0 { + return nil + } + return userTags +} diff --git a/errors/util.go b/errors/util.go index f398b96c..98ad8d81 100644 --- a/errors/util.go +++ b/errors/util.go @@ -43,20 +43,76 @@ func ErrStringify(buf *bytes.Buffer, err error) { ErrStringify(buf, Unwrap(err)) } +func errLayerData(err error) map[string]any { + switch e := err.(type) { + case *ErrWrap: + data := map[string]any{ + "caller": e.Caller, + "id": e.ID(), + } + if len(e.Tags) > 0 { + data["fields"] = e.Tags + } + if len(e.Stacks) > 0 { + data["stacks"] = e.Stacks + } + return data + case *Err: + data := map[string]any{ + "err_msg": e.Msg, + "id": e.ID(), + } + if e.Detail != "" { + data["detail"] = e.Detail + } + if len(e.Tags) > 0 { + data["tags"] = e.Tags + } + return data + case Err: + data := map[string]any{ + "err_msg": e.Msg, + "id": e.ID(), + } + if e.Detail != "" { + data["detail"] = e.Detail + } + if len(e.Tags) > 0 { + data["tags"] = e.Tags + } + return data + default: + data := map[string]any{ + "err_msg": err.Error(), + "err_detail": fmt.Sprintf("%v", err), + } + if marshaler, ok := err.(json.Marshaler); ok { + raw, marshalErr := marshaler.MarshalJSON() + if marshalErr == nil { + var extra map[string]any + if json.Unmarshal(raw, &extra) == nil { + delete(extra, "cause") + for key, value := range extra { + if _, exists := data[key]; !exists { + data[key] = value + } + } + } + } + } + return data + } +} + func ErrJsonify(err error) map[string]any { if err == nil { return make(map[string]any) } - data := make(map[string]any, 6) - if _err, ok := err.(json.Marshaler); ok { - data["cause"] = _err - } else { - data["err_msg"] = err.Error() - data["err_detail"] = fmt.Sprintf("%v", err) - err = Unwrap(err) - if err != nil { - data["cause"] = ErrJsonify(err) + data := errLayerData(err) + if next := Unwrap(err); next != nil { + if cause := ErrJsonify(next); len(cause) > 0 { + data["cause"] = cause } } return data diff --git a/errors/wrap.go b/errors/wrap.go index 2adc9f49..19e46c9e 100644 --- a/errors/wrap.go +++ b/errors/wrap.go @@ -73,16 +73,5 @@ func (e *ErrWrap) String() string { } func (e *ErrWrap) MarshalJSON() ([]byte, error) { - data := ErrJsonify(e.Err) - if len(e.Tags) > 0 { - data["fields"] = e.Tags - } - - if len(e.Stacks) > 0 { - data["stacks"] = e.Stacks - } - - data["caller"] = e.Caller - data["id"] = e.ID() - return json.Marshal(data) + return json.Marshal(ErrJsonify(e)) } diff --git a/internal/example/errors/main.go b/internal/example/errors/main.go index 6e192142..3818d0b9 100644 --- a/internal/example/errors/main.go +++ b/internal/example/errors/main.go @@ -1,36 +1,66 @@ package main import ( - "github.com/pubgo/funk/v2/assert" + "fmt" + "github.com/pubgo/funk/v2/errors" + "github.com/pubgo/funk/v2/errors/errcode" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/proto/errorpb" "github.com/pubgo/funk/v2/recovery" + "github.com/pubgo/funk/v2/result" ) -// 单个pkg的error处理 +func main() { + defer recovery.Exit() -var err1 = &errors.Err{Msg: "业务错误处理", Detail: "详细信息"} + demoBasicErrors() + demoResultAndLog() + demoErrCode() +} -func Hello() { - defer recovery.Raise(func(err error) error { - return errors.Wrap(err, "Hello wrap") +func demoBasicErrors() { + err := errors.New("database connection failed", errors.Tags{ + "component": "database", + "host": "localhost:5432", }) + err = errors.Wrap(err, "initialize payment service") - err2 := errors.Wrapf(err1, "处理 wrap") - assert.MustF(err2, "处理 panic") + fmt.Println("root message:", err.Error()) + fmt.Println("full chain:", errors.FormatChain(err)) + fmt.Println("user tags:", errors.CollectUserTags(err)) + fmt.Println("error id:", errors.GetErrorId(err)) } -func CallHello() (gErr error) { - defer recovery.Recovery(func(err error) { - gErr = errors.Wrap(err, "CallHello wrap") - }) - - Hello() +func demoResultAndLog() { + r := result.ErrOf(errors.Wrap( + errors.New("not found", errors.Tags{"user_id": 42}), + "load user", + )) - return + if r.IsErr() { + log.Error(). + Str("message", r.Message()). + Interface("tags", r.Tags()). + Err(r.Err()). + Msg("operation failed") + } } -func main() { - defer recovery.Exit() +func demoErrCode() { + const name = "example.demo.not_found" + _ = errcode.RegisterErrCode(&errorpb.ErrCode{ + Code: 404, + Message: "resource not found", + Name: name, + StatusCode: errorpb.Code_NotFound, + }) + + code, ok := errcode.LookupErrCode(name) + if !ok { + panic("missing registered error code") + } - assert.Must(CallHello()) + err := errcode.NewCodeErr(code) + fmt.Println(errors.FormatChain(err)) } diff --git a/log/README.md b/log/README.md index d14ec528..c399bb8a 100644 --- a/log/README.md +++ b/log/README.md @@ -20,6 +20,7 @@ The logging module provides a high-performance, structured logging system based - `CreateFieldsCtx(...)` snapshots the initial field map so later mutations to the caller's map do not leak into logging context. - `WithLogger(...)`, `WithFields(...)`, `GetFieldsFromCtx(...)`, and `WithDisabled(...)` are nil-safe helpers for request-scoped logging flows. - Global helpers such as `log.Info(ctx)` and `log.Err(err, ctx)` honor the logger stored in the context. +- `log.Err(err, ctx)` automatically adds `error_id`, `error_chain`, `error_tags`, and `error_detail` for funk errors. - `FromCtx(ctx)` is the ergonomic way to fetch the effective logger from a context when you need to keep chaining logger methods. - `UpdateFieldsCtx(...)` returns a new context and does not mutate field maps already stored in parent contexts. - When logger fields and context fields use the same key, **context fields win**. This lets request-scoped metadata override module defaults safely. diff --git a/log/impl.log.go b/log/impl.log.go index 2759a4c5..635f47fe 100644 --- a/log/impl.log.go +++ b/log/impl.log.go @@ -150,6 +150,14 @@ func (l *loggerImpl) Err(err error, ctxL ...context.Context) *zerolog.Event { e.Str("error_id", id) } + if chain := errors.FormatChain(err); chain != "" { + e.Str("error_chain", chain) + } + + if tags := errors.CollectUserTags(err); len(tags) > 0 { + e.Interface("error_tags", tags) + } + e.Str("error_detail", errDetail(err)) e.Str(zerolog.ErrorFieldName, err.Error()) } diff --git a/result/error.go b/result/error.go index b7b44847..9a24f9d4 100644 --- a/result/error.go +++ b/result/error.go @@ -154,6 +154,22 @@ func (e Error) Err() error { return e.getErr() } func (e Error) GetErr() error { return e.getErr() } +// Message returns the full human-readable error chain. +func (e Error) Message() string { + if e.IsOK() { + return "" + } + return errors.FormatChain(e.err) +} + +// Tags returns user tags collected from the error chain. +func (e Error) Tags() errors.Tags { + if e.IsOK() { + return nil + } + return errors.CollectUserTags(e.err) +} + func (e Error) Unwrap() (void Void) { if e.IsErr() { panicIfError(errors.WrapCaller(e.getErr(), 1)) @@ -191,7 +207,7 @@ func (e Error) String() string { return "OK" } - return fmt.Sprintf("Error(%v)", e.err) + return fmt.Sprintf("Error(%s)", errors.FormatChain(e.err)) } func (e Error) MarshalJSON() ([]byte, error) { diff --git a/result/z_error_methods_test.go b/result/z_error_methods_test.go index f6c68c04..534de808 100644 --- a/result/z_error_methods_test.go +++ b/result/z_error_methods_test.go @@ -87,4 +87,14 @@ func TestErrorGettersAndInspect(t *testing.T) { assert.True(t, called) assert.Equal(t, "OK", result.ErrOf(nil).String()) + assert.Empty(t, result.ErrOf(nil).Message()) + assert.Nil(t, result.ErrOf(nil).Tags()) + + wrapped := result.ErrOf(errors.Wrap( + errors.New("not found", errors.Tags{"user_id": 42}), + "load user", + )) + assert.Contains(t, wrapped.Message(), "load user") + assert.Contains(t, wrapped.Message(), "not found") + assert.Equal(t, 42, wrapped.Tags()["user_id"]) }