From 211d7618d157cb70211c60421cec14288dce570d Mon Sep 17 00:00:00 2001 From: Anthony TREUILLIER Date: Mon, 9 Mar 2026 15:07:58 +0100 Subject: [PATCH] refactor: Refactor to conform to DESIGN.md Refs: MK8S-183 Signed-off-by: Anthony TREUILLIER --- DESIGN.md | 20 +- README.md | 265 ++++++------ errors.go | 337 ++++++++------- errors_test.go | 1078 ++++++++++++++++++------------------------------ 4 files changed, 744 insertions(+), 956 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 372b687..f89d13f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,6 +4,7 @@ This library aims to simplify error handling in Go through the following key fea * Variadic options following the "Functional Options" pattern * An error code, obtained by concatenating identifiers defined at each calls of subfunctions, that allow tracing the root cause * Comply with error interface (Error() string) +* JSON formatting message with markers # One unique function The signature of this unique function would be: @@ -34,12 +35,11 @@ Signature could be as follow errors.WithDetail(string) errors.WithDetailf(string, ...any) errors.WithProperty(string, any) -errors.WithIdentifier(int) +errors.WithIdentifier(uint32) errors.CausedBy(err error) ``` # Identifier - Below an example of using `go-errors` with identifier. Also, find the expected error message @@ -57,7 +57,7 @@ var ErrForbidden = errors.New("forbidden") func main(){ err := call1() - fmt.Println(err) + fmt.Println(err.Error()) } func call1() error { @@ -85,9 +85,9 @@ func call3() error { // Something went wrong here return errors.Wrap( ErrForbidden, - errors.WithIdentifer(2), + errors.WithIdentifier(2), errors.WithDetail("permission denied"), - errors.WithProperty("File", "test.txt"), + errors.WithProperty("File", "test.txt"), errors.CausedBy(err), ) } @@ -95,5 +95,11 @@ func call3() error { In the following situation, the program would return ``` -forbidden (19-12-2): permission denied: missing required role: Role='Reader', User='john.doe', File='test.txt', at=(func='main.call3', file='main.go', line='41'), caused by: open test.txt: permission denied -``` \ No newline at end of file +forbidden (19-12-2): permission denied: missing required role: Role='Reader', User='john.doe', File='test.txt', at=[(func='main.call3', file='main.go', line='40'), (func='main.call2', file='main.go', line='27'), (func='main.call1', file='main.go', line='20')], caused by: open test.txt: permission denied +``` + +# JSON formatting message +| Marker | Description | +| ------ | -------------------------- | +| `%v` | JSON (without stack) | +| `%+v` | Extended JSON (with stack) | diff --git a/README.md b/README.md index d5ffa3f..773904e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ A Go library for enhanced error handling with stack traces, structured error inf ## Features -- **Stack Traces**: Automatic capture of function location, file, line number, and timestamp +- **Stack Traces**: Automatic capture of function location, file and line number - **Error Wrapping**: Chain errors with causes for better error context -- **Structured Errors**: Add identifiers, details, and arbitrary properties to errors -- **Method Chaining**: Fluent API for building detailed error information +- **Structured Errors**: Add identifiers, details, and arbitrary properties via options +- **Single Entry Point**: `Wrap(error, ...Option)` works with both standard errors and go-errors - **Standard Library Compatible**: Implements standard error interface and works with `errors.Is()`, `errors.As()`, and `errors.Unwrap()` - **JSON Serialization**: Built-in JSON marshaling for logging and debugging @@ -23,19 +23,19 @@ go get github.com/scality/go-errors package main import ( - "fmt" - "github.com/scality/go-errors" + "fmt" + + "github.com/scality/go-errors" ) func main() { - var ErrDB = errors.New("database error") - err := errors.From(ErrDB). - WithIdentifier(1001). - WithDetail("connection timeout"). - WithProperty("host", "localhost"). - Throw() - - fmt.Println(err) + var ErrDB = errors.New("database error") + err := errors.Wrap(ErrDB, + errors.WithIdentifier(1001), + errors.WithDetail("connection timeout"), + errors.WithProperty("host", "localhost"), + ) + fmt.Println(err) } ``` @@ -44,144 +44,140 @@ func main() { ### Adding Details and Properties ```go -// Initializing Domain errors +// Define domain errors with New() var ( - ErrValidationFailed = errors.New("validation failed") - ErrRequestFailed = errors.New("request failed") - ErrDatabaseError = errors.New("database error") + ErrValidationFailed = errors.New("validation failed") + ErrRequestFailed = errors.New("request failed") + ErrDatabaseError = errors.New("database error") +) + +// Multiple details: each WithDetail() / WithDetailf() appends to the details slice +err1 := errors.Wrap(ErrValidationFailed, + errors.WithDetail("email is required"), + errors.WithDetail("password must be at least 8 characters"), ) -// Multiple details -// Each WithDetail() call appends to the Details slice -err1 := errors.From(ErrValidationFailed). - WithDetail("email is required"). - WithDetail("password must be at least 8 characters"). - Throw() - -// Details can also be formatted -err2 := errors.From(ErrRequestFailed). - WithDetailf("failed to connect to %s:%d", "api.example.com", 443). - WithDetail("timeout after 30 seconds"). - Throw() - -// Single property -err3 := errors.From(ErrRequestFailed). - WithProperty("url", "https://api.example.com"). - WithProperty("status_code", 500). - Throw() - -// Multiple properties at once -err4 := errors.From(ErrDatabaseError). - WithProperties(map[string]any{ - "host": "localhost", - "port": 5432, - "database": "myapp", - }). - Throw() -``` - -### Error Wrapping +// Formatted details +err2 := errors.Wrap(ErrRequestFailed, + errors.WithDetailf("failed to connect to %s:%d", "api.example.com", 443), + errors.WithDetail("timeout after 30 seconds"), +) + +// Properties (key-value pairs) +err3 := errors.Wrap(ErrRequestFailed, + errors.WithProperty("url", "https://api.example.com"), + errors.WithProperty("status_code", 500), +) + +// Multiple properties (one option per property) +err4 := errors.Wrap(ErrDatabaseError, + errors.WithProperty("host", "localhost"), + errors.WithProperty("port", 5432), + errors.WithProperty("database", "myapp"), +) +``` + +### CausedBy ```go var ErrUserNotFound = errors.New("user not found") func getUserByID(id string) (*User, error) { - user, err := db.Query(id) - if err != nil { - return nil, errors.From(ErrUserNotFound). - WithIdentifier(404000). - CausedBy(err). - Throw() - } - return user, nil + user, err := db.Query(id) + if err != nil { + return nil, errors.Wrap(ErrUserNotFound, + errors.WithIdentifier(404000), + errors.CausedBy(err), + ) + } + return user, nil } // Convenient wrapping with Wrap() - adds message and stack trace func getUser(id int) (*User, error) { - user, err := db.Query(id) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch user from database") - } - return user, nil -} - -// Convenient wrapping with Wrapf() - adds formatted message and stack trace -func getUser(id int) (*User, error) { - user, err := db.Query(id) - if err != nil { - return nil, errors.Wrapf(err, "failed to fetch user with id %d", id) - } - return user, nil + user, err := db.Query(id) + if err != nil { + return nil, errors.Wrap(err, + errors.WithDetail("failed to fetch user from database"), + ) + } + return user, nil } ``` -### Stack Traces +### Identifier Concatenation + +Each call to `Wrap` can add an identifier segment; segments are concatenated with `-` to trace the error path through the call stack (e.g. `19-12-2`). ```go -var ErrSomethingWentWrong = errors.New("something went wrong") +var ErrForbidden = errors.New("forbidden") -func layer3() error { - return errors.From(ErrSomethingWentWrong).Throw() +func call1() error { + return errors.Wrap(call2(), errors.WithIdentifier(19)) } -func layer2() error { - err := layer3() - if err != nil { - return errors.Stamp(err) // Adds layer2's location to stack - } - return nil +func call2() error { + return errors.Wrap(call3(), + errors.WithDetail("missing required role"), + errors.WithProperty("Role", "Reader"), + errors.WithIdentifier(12), + ) } -func layer1() error { - err := layer2() - if err != nil { - return errors.Stamp(err) // Adds layer1's location to stack - } - return nil +func call3() error { + _, err := os.Open("test.txt") + return errors.Wrap(ErrForbidden, + errors.WithIdentifier(2), + errors.WithDetail("permission denied"), + errors.WithProperty("File", "test.txt"), + errors.CausedBy(err), + ) } - -// The error will contain a complete stack trace through all layers ``` ### Working with Standard Library ```go -// Using errors.Is for comparison -notFoundErr := errors.New("not found") - +// Using errors.Is for comparison (compares Title and Identifier) if errors.Is(err, notFoundErr) { - // Handle not found error + // Handle not found error } -// Using errors.As for type assertion +// Using errors.As to access structured fields var e *errors.Error if errors.As(err, &e) { - fmt.Printf("Error ID: %d\n", e.Identifier) - fmt.Printf("Details: %v\n", e.Details) // []string - fmt.Printf("Properties: %v\n", e.Properties) - - // Access individual details - for i, detail := range e.Details { - fmt.Printf(" Detail %d: %s\n", i, detail) - } + fmt.Printf("Title: %s\n", e.Title) + fmt.Printf("Identifier: %v\n", e.Identifier) // []uint32 + fmt.Printf("Details: %v\n", e.Details) // []string + fmt.Printf("Properties: %v\n", e.Properties) + + for i, detail := range e.Details { + fmt.Printf(" Detail %d: %s\n", i, detail) + } } ``` ### Converting Standard Errors ```go -// Using From() to convert any error +// Wrap any standard error to add stack trace, details, and properties stdErr := fmt.Errorf("something went wrong") -err := errors.From(stdErr). - WithDetail("additional context"). - Throw() - -// Using Intercept() to complete the error -func handleError(err error) error { - e := errors.Intercept(err) - e.WithProperty("handled_at", time.Now()) - return e.Throw() -} +err := errors.Wrap(stdErr, + errors.WithDetail("additional context"), + errors.WithProperty("source", "legacy"), +) +``` + +### Specific use-case with Is() + +Is() compares this error with another error for equality. Two errors match if they have same Title and same Identifier* +(*) or if one is a parent of the other. + +For example: +If e1.Identifier: "2-1" and e2.Identifier: "3-2-1", then +```go +e2.Is(e1) return True // e1 is a parent of e2 +e1.Is(e2) return False ``` ## Output Format @@ -189,37 +185,54 @@ func handleError(err error) error { The `Error()` method produces output in the following format: ``` -title (id): detail1: detail2: detail3: key1='value1', key2='value2', at=(func='funcName', file='file.go', line='10'), caused by: underlying error +title (id): detail1: detail2: detail3: key1='value1', key2='value2', at=[(func='func1Name', file='file.go', line='21'), (func='func2Name', file='file.go', line='10')], caused by: underlying error ``` -**Note**: Details are stored as a slice and joined with `: ` when the error is formatted. Each call to `WithDetail()` or `WithDetailf()` appends to this slice. +Details are stored as a slice and joined with `: ` when the error is formatted. Each `WithDetail()` or `WithDetailf()` option appends to this slice. Example with multiple details: + ``` -database error (1001): connection timeout: retry limit exceeded: host='localhost', port='5432', at=(func='connectDB', file='db.go', line='42'), caused by: dial tcp: connection refused +database error (1001): connection timeout: retry limit exceeded: host='localhost', port='5432', at=[(func='connectDB', file='db.go', line='42')], caused by: dial tcp: connection refused ``` Example with wrapped error: + +``` +unknown error (0): failed to fetch user from database: at=[(func='getUser', file='user.go', line='25')], caused by: connection refused +``` + +## JSON formatting message +| Marker | Description | +| ------ | -------------------------- | +| `%v` | JSON (without stack) | +| `%+v` | Extended JSON (with stack) | + +Example with JSON without stack (%v): + +``` +{"title":"forbidden","identifier":[2,12,19],"details":["permission denied","missing required role"],"properties":{"File":"test.txt","Role":"Reader"},"cause":"open test.txt: permission denied"} +``` + +Example with extended JSON, with stack (%+v): + ``` -unknown error (0): failed to fetch user from database: at=(func='getUser', file='user.go', line='25'), caused by: connection refused +{"title":"forbidden","identifier":[2,12,19],"details":["permission denied","missing required role"],"properties":{"File":"test.txt","Role":"Reader"},"cause":"open test.txt: permission denied","stack":[{"function":"main.call1","file":"/path/to/main.go","line":25},{"function":"main.call2","file":"/path/to/main.go","line":29},{"function":"main.call3","file":"/path/to/main.go","line":38}]} ``` ## Best Practices -1. **Always use `Throw()`** when returning errors to capture stack traces -2. **Use `Stamp()`** when passing errors up the call stack to track the error path -3. **Use `Wrap()` or `Wrapf()`** for convenient error wrapping with automatic stack traces -4. **Use identifiers** for errors that need programmatic handling (e.g., HTTP status codes) -5. **Add multiple details** using `WithDetail()` or `WithDetailf()` - they're stored as a slice and displayed in order -6. **Add properties** for debugging context (IDs, URLs, parameters, etc.) -7. **Wrap errors with `CausedBy()`** to maintain error chains for programmatic inspection -8. **Use `From()`** when you need to enhance third-party errors with additional context -9. **Use `Intercept()`** when you need to add context to errors from existing errors +1. **Use `Wrap(err, ...options)`** as the single entry point for both standard errors and go-errors; it captures stack traces and applies options. +2. **Use identifiers** for errors that need programmatic handling (e.g., HTTP status codes); they are concatenated across the call stack when re-wrapping. +3. **Add details** with `WithDetail()` or `WithDetailf()`; they are stored as a slice and displayed in order. +4. **Add properties** with `WithProperty(key, value)` for debugging context (IDs, URLs, parameters, etc.). +5. **Use `CausedBy(err)`** when wrapping to record the underlying cause and maintain error chains for `errors.Is` / `errors.Unwrap`. +6. **Use `New(title)`** to define sentinel errors; pass them to `Wrap` and add options at each layer. ### Details vs Properties -- **Details** (slice of strings): Human-readable context that appears in error messages, ordered and concatenated with `: ` -- **Properties** (key-value map): Structured data for debugging/logging, useful for searching and filtering logs +- **Details** (slice of strings): Human-readable context that appears in error messages, ordered and concatenated with `: `. +- **Properties** (key-value map): Structured data for debugging/logging, useful for searching and filtering logs. ## License diff --git a/errors.go b/errors.go index 07f28a4..4b93c21 100644 --- a/errors.go +++ b/errors.go @@ -5,11 +5,13 @@ import ( "encoding/json" "errors" "fmt" - "path" + "io" + "maps" "path/filepath" "runtime" + "slices" + "strconv" "strings" - "time" ) // Trace represents a single entry in an error's stack trace. @@ -23,9 +25,6 @@ type Trace struct { // The line where the error occurred Line int `json:"line,omitempty" yaml:"line,omitempty"` - - // The timestamp when trace was generated - Timestamp time.Time `json:"timestamp,omitempty" yaml:"timestamp,omitempty"` } // Error is an enhanced error implementation that supports structured error information, @@ -36,97 +35,131 @@ type Trace struct { type Error struct { // The error title Title string `json:"title" yaml:"title"` + // Options for the error + opts `json:",inline" yaml:",inline"` + // A trace of where the error was thrown + stack []*Trace +} - // A numeric error code for programmatic handling - Identifier int32 `json:"identifier,omitempty" yaml:"identifier,omitempty"` +type causeError struct { + error +} +type opts struct { + // A numeric error code for programmatic handling + Identifier []uint32 `json:"identifier,omitempty" yaml:"identifier,omitempty"` // Additional context as a list of strings Details []string `json:"details,omitempty" yaml:"details,omitempty"` - // Key-value pairs for arbitrary metadata Properties map[string]any `json:"properties,omitempty" yaml:"properties,omitempty"` - // The underlying error that caused this error - Cause error `json:"cause,omitempty" yaml:"cause,omitempty"` + Cause *causeError `json:"cause,omitempty" yaml:"cause,omitempty"` +} - // A trace of where the error was thrown +type errorWithStack struct { + Error Stack []*Trace `json:"stack,omitempty" yaml:"stack,omitempty"` } +func (e *Error) Format(s fmt.State, verb rune) { + if e == nil { + return + } + switch verb { + case 'v': + if s.Flag('+') { + json.NewEncoder(s).Encode(errorWithStack{Error: *e, Stack: e.stack}) //nolint:errcheck + return + } + json.NewEncoder(s).Encode(e) //nolint:errcheck + case 's', 'q': + io.WriteString(s, e.Error()) //nolint:errcheck + } +} + +func (c causeError) MarshalJSON() ([]byte, error) { + if t, ok := c.error.(*Error); ok { + return json.Marshal(t) + } + return json.Marshal(c.Error()) +} + // New creates a new Error with the given title. func New(title string) error { return &Error{ Title: title, + opts: opts{ + Details: []string{}, + Properties: make(map[string]any), + }, } } -// Wrap wraps an error with a message. -func Wrap(err error, msg string) error { - trace := trace() - return from(err, true).WithDetail(msg).throw(trace) -} +// Option is a function type that modifies the Options struct. +type Option func(*opts) -// Wrapf wraps an error with a formatted message. -func Wrapf(err error, format string, args ...any) error { - trace := trace() - return from(err, true).WithDetailf(format, args...).throw(trace) +// WithIdentifier sets a numeric identifier for the error. +func WithIdentifier(id uint32) Option { + return func(c *opts) { + c.Identifier = append(c.Identifier, id) + } } -// From creates a new *Error from any error type. -// If the error is not an *Error, it creates a new error with title "unknown error" -// and sets the original error as the cause. -// If the error is an *Error, it returns a copy of the original error with the same -// title, identifier, details, properties. -func From(err error) *Error { - return from(err, false) +// WithDetail sets a detail string for the error. +func WithDetail(msg string) Option { + return func(c *opts) { + c.Details = append(c.Details, msg) + } } -func from(err error, copyStack bool) *Error { - var t *Error - - ok := errors.As(err, &t) - if !ok { - t, _ = New("unknown error").(*Error) +// WithDetailf sets a detail string for the error using a format string. +func WithDetailf(format string, args ...any) Option { + return func(c *opts) { + c.Details = append(c.Details, fmt.Sprintf(format, args...)) } +} - e := &Error{ - Title: t.Title, - Identifier: t.Identifier, - Details: t.Details, - Properties: t.Properties, - } - if copyStack { - e.Stack = t.Stack +// WithProperty sets a property for the error. +func WithProperty(key string, value any) Option { + if key == "" { + return func(c *opts) {} } - - if !ok { - e.Cause = err + return func(c *opts) { + c.Properties[key] = value } - - return e } -// Intercept converts any error into an *Error type. -// If the provided error is already an *Error, it returns it as-is. -// Otherwise, it creates a new *Error wrapping the original error using From(). -func Intercept(err error) *Error { - var e *Error - if errors.As(err, &e) { - return e +// CausedBy sets the underlying cause of this error. +func CausedBy(err error) Option { + return func(c *opts) { + c.Cause = &causeError{error: err} } +} - return From(err) +// Wrap wraps an error with a message +// returns an "unknown error" when err is nil +func Wrap(err error, opts ...Option) error { + trace := trace() + return from(err, true, opts...).throw(trace) } // Is compares this error with another error for equality. -// Two errors are considered equal if they have the same Title and Identifier. +// Two errors match if they have same Title and same Identifier* +// (*) or if given argument is a parent of the other. func (e *Error) Is(err error) bool { other := new(Error) if ok := errors.As(err, &other); !ok { return false } - return e.Title == other.Title && e.Identifier == other.Identifier + if e.Title != other.Title { + return false + } + a, b := e.Identifier, other.Identifier + if len(a) < len(b) { + return false + } + return slices.Equal(b, a[:len(b)]) } // Is is a wrapper around errors.Is to compare two errors for equality. @@ -135,13 +168,26 @@ func Is(err, target error) bool { return errors.Is(err, target) } // As is a wrapper around errors.As to check if the error is of a specific type. func As(err error, target any) bool { return errors.As(err, target) } +// Unwrap returns the underlying cause of this error, nil if no cause. +func Unwrap(err error) error { + u, ok := err.(interface { + Unwrap() error + }) + if !ok { + return nil + } + return u.Unwrap() +} + // Unwrap returns the underlying cause of this error, nil if no cause. func (e *Error) Unwrap() error { if e == nil { return nil } - - return e.Cause + if e.Cause != nil { + return e.Cause.error + } + return nil } // Error returns a formatted string representation of the error, @@ -155,9 +201,9 @@ func (e *Error) Error() string { fmt.Fprintf( b, - "%s (%d):", + "%s (%s):", strings.ToLower(e.Title), - e.Identifier, + concatenateUint32Slice(e.Identifier), ) if len(e.Details) > 0 { @@ -169,92 +215,77 @@ func (e *Error) Error() string { ) } - for k, v := range e.Properties { + // order by keys before printing for deterministic output + keys := make([]string, 0, len(e.Properties)) + for k := range e.Properties { + keys = append(keys, k) + } + slices.Sort(keys) + for _, k := range keys { + v := e.Properties[k] fmt.Fprintf(b, " %s='%v',", k, v) } - if len(e.Stack) > 0 { - tail := e.Stack[len(e.Stack)-1] - - if tail != nil { - fmt.Fprintf( - b, - " at=(func='%s', file='%s', line='%d'),", - path.Base(tail.Function), - filepath.Base(tail.File), - tail.Line, + if len(e.stack) > 0 { + stack := make([]string, 0, len(e.stack)) + for i := len(e.stack) - 1; i >= 0; i-- { + trace := e.stack[i] + stack = append( + stack, + fmt.Sprintf( + "(func='%s', file='%s', line='%d')", + trace.Function, + filepath.Base(trace.File), + trace.Line, + ), ) } + fmt.Fprintf( + b, + " at=[%s]", + strings.Join(stack, ", "), + ) } if e.Cause != nil { - fmt.Fprintf(b, " caused by: %v", e.Cause.Error()) + fmt.Fprintf(b, ", caused by: %v", e.Cause.Error()) } return string(bytes.TrimSuffix(bytes.TrimSuffix(b.Bytes(), []byte(",")), []byte(":"))) } -// String returns a JSON representation of the error. -func (e *Error) String() string { - if e == nil { - return "" - } - - b := bytes.NewBuffer(nil) - json.NewEncoder(b).Encode(e) // nolint: errcheck // No way to get wrong here. - - return b.String() -} - -// Stamp adds a stack trace entry to an existing error. -func Stamp(err error) error { - trace := trace() - - return Intercept(err).throw(trace) -} - -// WithIdentifier sets a numeric identifier for the error. -func (e *Error) WithIdentifier(id int32) *Error { - e.Identifier = id - - return e -} - -// WithDetail adds a detail string to the error for additional context. -func (e *Error) WithDetail(detail string) *Error { - e.Details = append(e.Details, strings.TrimSuffix(detail, ".")) - - return e -} - -// WithDetailf adds a detail string to the error for additional context using a format string. -func (e *Error) WithDetailf(format string, args ...any) *Error { - return e.WithDetail(fmt.Sprintf(format, args...)) -} +func from(err error, copyStack bool, options ...Option) *Error { + var t *Error -// WithProperties adds multiple key-value properties to the error. -func (e *Error) WithProperties(properties map[string]any) *Error { - for k, v := range properties { - e.WithProperty(k, v) // nolint: errcheck // No way to get wrong here. + ok := errors.As(err, &t) + if !ok { + t, _ = New("unknown error").(*Error) } - return e -} - -// WithProperty adds a single key-value property to the error. -func (e *Error) WithProperty(key string, value any) *Error { - if e.Properties == nil { - e.Properties = make(map[string]any) + props := make(map[string]any, len(t.Properties)) + maps.Copy(props, t.Properties) + o := opts{ + Details: slices.Clone(t.Details), + Properties: props, + Cause: t.Cause, + } + if t.Identifier != nil { + o.Identifier = slices.Clone(t.Identifier) + } + for _, opt := range options { + opt(&o) + } + e := &Error{ + Title: t.Title, + opts: o, + } + if copyStack { + e.stack = t.stack } - e.Properties[key] = value - - return e -} - -// CausedBy sets the underlying cause of this error. -func (e *Error) CausedBy(err error) *Error { - e.Cause = err + if !ok && err != nil { + e.Cause = &causeError{error: err} + } return e } @@ -264,18 +295,11 @@ func (e *Error) throw(trace *Trace) error { return e } - e.Stack = append([]*Trace{trace}, e.Stack...) + e.stack = append([]*Trace{trace}, e.stack...) return e } -// Throw adds a stack trace entry to the error and returns it as an error interface. -func (e *Error) Throw() error { - trace := trace() - - return e.throw(trace) -} - func trace() *Trace { // 2 is the depth of the caller. pc, file, line, ok := runtime.Caller(2) @@ -289,9 +313,42 @@ func trace() *Trace { } return &Trace{ - Function: fn.Name(), - File: file, - Line: line, - Timestamp: time.Now().UTC(), + Function: fn.Name(), + File: file, + Line: line, + } +} + +// concatenateUint32Slice takes a slice of uint32 and returns a single reversed string +// with all elements joined by a hyphen ("-"). +func concatenateUint32Slice(nums []uint32) string { + if len(nums) == 0 { + return "" + } + + // Clone to avoid modifying the original slice. + clone := slices.Clone(nums) + slices.Reverse(clone) + + // Use a strings.Builder for efficient string concatenation. + var builder strings.Builder + + // Iterate over the slice elements reversly. + for i, cl := range clone { + // Convert the uint32 to its string representation. + // We use base 10 (decimal) and specify 32-bit type for clarity, + // though 'FormatInt' takes an int64 internally (uint32 is safely converted). + str := strconv.FormatInt(int64(cl), 10) + + // Write the string representation to the builder. + builder.WriteString(str) + + // Append the separator for all elements except the last one. + if i < len(clone)-1 { + builder.WriteString("-") + } } + + // Return the final concatenated string. + return builder.String() } diff --git a/errors_test.go b/errors_test.go index e880f4a..d8dd400 100644 --- a/errors_test.go +++ b/errors_test.go @@ -2,7 +2,9 @@ package errors import ( "errors" + "fmt" "regexp" + "strings" "testing" . "github.com/onsi/ginkgo/v2" @@ -17,15 +19,15 @@ func TestAPI(t *testing.T) { var ( ErrNotFound = New("not found") - ErrNotFoundWithDetails = &Error{ - Title: "not found", - Details: []string{"File not found in the session"}, - } - ErrNotFoundWithProperties = &Error{ + ErrNotFoundWithOptions = &Error{ Title: "not found", - Properties: map[string]any{ - "File": "test.txt", - "User": "john.doe", + opts: opts{ + Identifier: []uint32{1}, + Details: []string{"File not found in the session"}, + Properties: map[string]any{ + "File": "test.txt", + "User": "john.doe", + }, }, } ErrForbidden = New("forbidden") @@ -33,771 +35,481 @@ var ( errTest = errors.New("test error") errPerm = errors.New("permission denied") -) -var _ = BeforeSuite(func() { -}) + e, e1, e2 error +) var _ = Describe("Errors", func() { + Context("When creating an error with New()", func() { + It("should return an Error with the given title and default options", func() { + e := New("my error") + var err *Error + Expect(errors.As(e, &err)).To(BeTrue()) + Expect(err.Title).To(Equal("my error")) + Expect(err.Identifier).To(BeNil()) + Expect(err.Details).To(BeEmpty()) + Expect(err.Properties).To(BeEmpty()) + Expect(err.Cause).To(BeNil()) + }) + }) + Context("When creating a new error from a standard one", func() { - It("should return an Unknown Error error", func() { - e := From(errTest) - Expect(e.Title).To(Equal("unknown error")) - Expect(e.Identifier).To(BeZero()) - Expect(e.Details).To(BeEmpty()) - Expect(e.Properties).To(BeEmpty()) - Expect(e.Cause).To(Equal(errTest)) - Expect(e.Stack).To(BeEmpty()) + It("should return an Unknown Error error, when no options are provided", func() { + e = Wrap(errTest) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("unknown error")) + Expect(err.Identifier).To(BeZero()) + Expect(err.Details).To(BeEmpty()) + Expect(err.Properties).To(BeEmpty()) + Expect(err.Cause).To(Equal(&causeError{error: errTest})) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } + }) + It("should support WithDetailf for formatted details", func() { + e = Wrap(errTest, + WithDetailf("failed at %s:%d", "step", 42), + ) + var err *Error + Expect(errors.As(e, &err)).To(BeTrue()) + Expect(err.Details).To(Equal([]string{"failed at step:42"})) + }) + It("should return the error with the correct identifier, details, properties, when options are provided", func() { + e = Wrap(errTest, + WithIdentifier(1001), + WithDetail("This is a test error"), + WithProperty("type", "fake"), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("unknown error")) + Expect(err.Identifier).To(Equal([]uint32{1001})) + Expect(err.Details).To(Equal([]string{"This is a test error"})) + Expect(err.Properties).To(Equal(map[string]any{"type": "fake"})) + Expect(err.Cause).To(Equal(&causeError{error: errTest})) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } }) }) Context("When creating a new error from a custom one", func() { - It("should return the custom error", func() { - e := From(ErrNotFound). - WithDetail("File not found in the session."). - WithProperty("File", "test.txt") - Expect(e.Title).To(Equal("not found")) - Expect(e.Identifier).To(BeZero()) - Expect(e.Details).To(Equal([]string{"File not found in the session"})) - Expect(e.Properties).To(Equal(map[string]any{"File": "test.txt"})) - Expect(e.Cause).To(BeNil()) - Expect(e.Stack).To(BeEmpty()) + It("should return the custom error, when no options are provided", func() { + e = Wrap(ErrNotFound) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("not found")) + Expect(err.Identifier).To(BeZero()) + Expect(err.Details).To(BeEmpty()) + Expect(err.Properties).To(BeEmpty()) + Expect(err.Cause).To(BeNil()) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } + }) + It("should return the error with the correct identifier, details, properties, when error is enriched", func() { + e = Wrap(ErrNotFound, + WithDetail("File not found in the session"), + WithProperty("File", "test.txt"), + WithIdentifier(1001), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("not found")) + Expect(err.Identifier).To(Equal([]uint32{1001})) + Expect(err.Details).To(Equal([]string{"File not found in the session"})) + Expect(err.Properties).To(Equal(map[string]any{"File": "test.txt"})) + Expect(err.Cause).To(BeNil()) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + } }) }) - Context("When creating a new error from a custom one with details", func() { - It("should return the custom error with same details", func() { - e := From(ErrNotFoundWithDetails) - Expect(e.Title).To(Equal("not found")) - Expect(e.Identifier).To(BeZero()) - Expect(e.Details).To(Equal([]string{"File not found in the session"})) - Expect(e.Properties).To(BeEmpty()) - Expect(e.Cause).To(BeNil()) - Expect(e.Stack).To(BeEmpty()) + Context("When creating a new error from a custom one with options", func() { + It("should return the custom error with same options", func() { + e = Wrap(ErrNotFoundWithOptions) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("not found")) + Expect(err.Identifier).To(Equal([]uint32{1})) + Expect(err.Details).To(Equal([]string{"File not found in the session"})) + Expect(err.Properties).To(Equal(map[string]any{"File": "test.txt", "User": "john.doe"})) + Expect(err.Cause).To(BeNil()) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } + }) + It("should return the error with the correct identifier, details, properties, when error is enriched", func() { + e = Wrap(ErrNotFoundWithOptions, + WithIdentifier(1001), + WithDetail("custom client role is 'Reader'"), + WithProperty("ClientID", "1234567890"), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("not found")) + Expect(err.Identifier).To(Equal([]uint32{1, 1001})) + Expect(err.Details).To(Equal([]string{"File not found in the session", "custom client role is 'Reader'"})) + Expect(err.Properties).To(Equal(map[string]any{"File": "test.txt", "User": "john.doe", "ClientID": "1234567890"})) + Expect(err.Cause).To(BeNil()) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } }) - }) - Context("When creating a new error from a custom one with properties", func() { - It("should return the custom error with same properties", func() { - e := From(ErrNotFoundWithProperties) - Expect(e.Title).To(Equal("not found")) - Expect(e.Identifier).To(BeZero()) - Expect(e.Details).To(BeEmpty()) - Expect(e.Properties).To(Equal(map[string]any{"File": "test.txt", "User": "john.doe"})) - Expect(e.Cause).To(BeNil()) - Expect(e.Stack).To(BeEmpty()) + It("should return the error without adding an empty property", func() { + e = Wrap(ErrNotFoundWithOptions, + WithIdentifier(1001), + WithDetail("custom client role is 'Reader'"), + WithProperty("", "test"), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("not found")) + Expect(err.Identifier).To(Equal([]uint32{1, 1001})) + Expect(err.Details).To(Equal([]string{"File not found in the session", "custom client role is 'Reader'"})) + Expect(err.Properties).To(Equal(map[string]any{"File": "test.txt", "User": "john.doe"})) + Expect(err.Cause).To(BeNil()) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } }) }) - Context("When creating a new error from a custom one based on a standard error", func() { + Context("When creating a new error from a custom one caused by a standard error", func() { It("should return the custom error", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing role 'admin' for the user."). - WithProperty("User", "john.doe"). - CausedBy(errPerm) - Expect(e.Title).To(Equal("forbidden")) - Expect(e.Identifier).To(Equal(int32(403001))) - Expect(e.Details).To(Equal([]string{"missing role 'admin' for the user"})) - Expect(e.Properties).To(Equal(map[string]any{"User": "john.doe"})) - Expect(e.Cause).To(Equal(errPerm)) - Expect(e.Stack).To(BeEmpty()) + e = Wrap(ErrForbidden, + WithIdentifier(403001), + WithDetail("missing role 'admin' for the user"), + WithProperty("User", "john.doe"), + CausedBy(errPerm), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("forbidden")) + Expect(err.Identifier).To(Equal([]uint32{403001})) + Expect(err.Details).To(Equal([]string{"missing role 'admin' for the user"})) + Expect(err.Properties).To(Equal(map[string]any{"User": "john.doe"})) + Expect(err.Cause).To(Equal(&causeError{error: errPerm})) + Expect(len(err.stack)).To(Equal(1)) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + } }) }) - Context("When wrapping errors", func() { - It("should return the wrapped error", func() { - e1 := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing write permission on the file."). - WithProperty("File", "test.txt"). - CausedBy(errPerm) - e2 := Intercept(e1). - WithDetail("missing role 'admin' for the user."). - WithProperty("User", "john.doe") - Expect(e2.Title).To(Equal("forbidden")) - Expect(e2.Identifier).To(Equal(int32(403001))) - Expect(e2.Details).To(Equal([]string{"missing write permission on the file", "missing role 'admin' for the user"})) - Expect(e2.Properties).To(Equal(map[string]any{"File": "test.txt", "User": "john.doe"})) - Expect(e2.Cause).To(Equal(errPerm)) + Context("When wrapping an error", func() { + It("should return the wrapped error, when not enriched", func() { + e1 = Wrap(ErrInternal, + WithIdentifier(500001), + WithDetail("unexpected error occurred while processing the request"), + WithProperty("RequestID", "1234567890"), + CausedBy(errPerm), + ) + e = Wrap(e1) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("internal error")) + Expect(err.Identifier).To(Equal([]uint32{500001})) + Expect(err.Details).To(Equal([]string{"unexpected error occurred while processing the request"})) + Expect(err.Properties).To(Equal(map[string]any{"RequestID": "1234567890"})) + Expect(err.Cause).To(Equal(&causeError{error: errPerm})) + Expect(len(err.stack)).To(Equal(2)) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + Expect(err.stack[1].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[1].Line).To(BeNumerically(">", 0)) + Expect(err.stack[1].Function).NotTo(BeEmpty()) + } }) - }) - - Context("When stamping an error", func() { - It("should return the error with a stack trace", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing write permission on the file."). - WithProperty("File", "test.txt"). - CausedBy(errPerm) - err := Intercept(Stamp(e)) - Expect(err.Title).To(Equal("forbidden")) - Expect(err.Identifier).To(Equal(int32(403001))) - Expect(err.Details).To(Equal([]string{"missing write permission on the file"})) - Expect(err.Properties).To(Equal(map[string]any{"File": "test.txt"})) - Expect(err.Cause).To(Equal(errPerm)) + It("should return the wrapped error with additional information, when enriched", func() { + e1 = Wrap(ErrInternal, + WithIdentifier(500002), + WithDetail("database connection failed"), + WithProperty("RequestID", "1234567890"), + CausedBy(errPerm), + ) + + e = Wrap(e1, + WithDetail("wrong url"), + WithProperty("url", "https://bdd.fake.com"), + ) + var err *Error + if ok := errors.As(e, &err); ok { + Expect(err.Title).To(Equal("internal error")) + Expect(err.Identifier).To(Equal([]uint32{500002})) + Expect(err.Details).To(Equal([]string{"database connection failed", "wrong url"})) + Expect(err.Properties).To(Equal(map[string]any{"RequestID": "1234567890", "url": "https://bdd.fake.com"})) + Expect(err.Cause).To(Equal(&causeError{error: errPerm})) + Expect(len(err.stack)).To(Equal(2)) + Expect(err.stack[0].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[0].Line).To(BeNumerically(">", 0)) + Expect(err.stack[0].Function).NotTo(BeEmpty()) + Expect(err.stack[1].File).To(ContainSubstring("errors_test.go")) + Expect(err.stack[1].Line).To(BeNumerically(">", 0)) + Expect(err.stack[1].Function).NotTo(BeEmpty()) + } }) }) Context("When printing an error", func() { It("should return the error as a string", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing write permission on the file."). - WithProperty("File", "test.txt"). - CausedBy(errPerm) - result := Stamp(e).Error() + e1 = Wrap(ErrForbidden, + WithIdentifier(128), + WithDetail("missing write permission on the file"), + WithProperty("File", "test.txt"), + CausedBy(errPerm), + ) + e = Wrap(e1, + WithIdentifier(932), + WithDetail("custom client role is 'Reader'"), + WithProperty("ClientID", "1234567890"), + ) + result := e.Error() // Replace line number and function references result = regexp.MustCompile(`line='\d+'`).ReplaceAllString(result, "line=''") - result = regexp.MustCompile(`func='[a-z0-9\.\-]*'`).ReplaceAllString(result, "func=''") - expected := "forbidden (403001): missing write permission on the file: File='test.txt'," + - " at=(func='', file='errors_test.go', line='')," + - " caused by: permission denied" - Expect(result).To(Equal(expected)) + result = regexp.MustCompile(`func='[a-z0-9\/\.\-]*'`).ReplaceAllString(result, "func=''") + Expect(result).To(ContainSubstring("forbidden (932-128):")) + Expect(result).To(ContainSubstring("custom client role is 'Reader':")) + Expect(result).To(ContainSubstring("missing write permission on the file:")) + Expect(result).To(ContainSubstring("File='test.txt'")) + Expect(result).To(ContainSubstring("ClientID='1234567890'")) + Expect(result).To(ContainSubstring("at=[(func='', file='errors_test.go', line=''), (func='', file='errors_test.go', line='')]")) + Expect(result).To(ContainSubstring("caused by: permission denied")) }) }) Context("When getting JSON representation of an error", func() { - It("should return the error as JSON string", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing write permission on the file"). - WithProperty("File", "test.txt"). - CausedBy(errPerm) - result := e.String() - Expect(result).To(ContainSubstring(`"title":"forbidden"`)) - Expect(result).To(ContainSubstring(`"identifier":403001`)) - Expect(result).To(ContainSubstring(`"details":`)) - Expect(result).To(ContainSubstring(`"properties":`)) - }) - - It("should handle nil error", func() { - var e *Error - Expect(e.String()).To(Equal("")) - }) - }) - - Context("When comparing errors with Is()", func() { - It("should return true for errors with same title and identifier", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := From(ErrForbidden).WithIdentifier(403001) - Expect(e1.Is(e2)).To(BeTrue()) - }) - - It("should return false for errors with different titles", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := From(ErrNotFound).WithIdentifier(403001) - Expect(e1.Is(e2)).To(BeFalse()) - }) - - It("should return false for errors with different identifiers", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := From(ErrForbidden).WithIdentifier(403002) - Expect(e1.Is(e2)).To(BeFalse()) - }) - - It("should return false for standard errors", func() { - e := From(ErrForbidden).WithIdentifier(403001) - Expect(e.Is(errTest)).To(BeFalse()) + It("should support the %v marker to print the error as simple JSON string", func() { + e1 = Wrap(ErrForbidden, + WithIdentifier(127), + WithDetail("missing write permission on the file"), + WithProperty("File", "test.txt"), + CausedBy(errPerm), + ) + e = Wrap(e1, + WithIdentifier(432), + WithDetail("custom client role is 'Reader'"), + WithProperty("ClientID", "1234567890"), + ) + result := fmt.Sprintf("%v", e) + + // Replace newline character + result = strings.TrimSuffix(result, "\n") + expected := "{" + + "\"title\":\"forbidden\"," + + "\"identifier\":[127,432]," + + "\"details\":[\"missing write permission on the file\",\"custom client role is 'Reader'\"]," + + "\"properties\":{\"ClientID\":\"1234567890\",\"File\":\"test.txt\"}," + + "\"cause\":\"permission denied\"" + + "}" + Expect(result).To(Equal(expected)) }) - It("should work with errors.Is() from standard library", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := From(ErrForbidden).WithIdentifier(403001) - Expect(errors.Is(e1, e2)).To(BeTrue()) + It("should support the %+v marker to print the error as extended JSON string", func() { + e1 = Wrap(ErrForbidden, + WithIdentifier(127), + WithDetail("missing write permission on the file"), + WithProperty("File", "test.txt"), + CausedBy(errPerm), + ) + e = Wrap(e1, + WithIdentifier(432), + WithDetail("custom client role is 'Reader'"), + WithProperty("ClientID", "1234567890"), + ) + result := fmt.Sprintf("%+v", e) + + // Replace newline character, line numbers and function references + result = strings.TrimSuffix(result, "\n") + result = regexp.MustCompile(`"line":\d+`).ReplaceAllString(result, "\"line\":0") + result = regexp.MustCompile(`"function":"[a-z0-9\/\.\-]*"`).ReplaceAllString(result, "\"function\":\"\"") + result = regexp.MustCompile(`"file":"[a-zA-Z0-9\/\.\-_]*"`).ReplaceAllString(result, "\"file\":\"\"") + expected := "{" + + "\"title\":\"forbidden\"," + + "\"identifier\":[127,432]," + + "\"details\":[\"missing write permission on the file\",\"custom client role is 'Reader'\"]," + + "\"properties\":{\"ClientID\":\"1234567890\",\"File\":\"test.txt\"}," + + "\"cause\":\"permission denied\"," + + "\"stack\":[{\"function\":\"\",\"file\":\"\",\"line\":0},{\"function\":\"\",\"file\":\"\",\"line\":0}]" + + "}" + Expect(result).To(Equal(expected)) }) }) - Context("When unwrapping errors", func() { - It("should return the cause when present", func() { - e := From(ErrForbidden).CausedBy(errPerm) - Expect(e.Unwrap()).To(Equal(errPerm)) - }) - - It("should return nil when no cause", func() { - e := From(ErrForbidden) - Expect(e.Unwrap()).To(BeNil()) - }) - - It("should return nil for nil error", func() { + Context("When calling Error() on nil", func() { + It("should return empty string", func() { var e *Error - Expect(e.Unwrap()).To(BeNil()) - }) - - It("should work with errors.Unwrap() from standard library", func() { - e := From(ErrForbidden).CausedBy(errPerm) - Expect(errors.Unwrap(e)).To(Equal(errPerm)) + Expect(e.Error()).To(Equal("")) }) }) - Context("When using Throw() method", func() { - It("should add stack trace to the error", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("missing write permission on the file"). - WithProperty("File", "test.txt") - err := Intercept(e.Throw()) - Expect(err.Stack).NotTo(BeEmpty()) - Expect(len(err.Stack)).To(Equal(1)) - Expect(err.Stack[0].File).To(ContainSubstring("errors_test.go")) - Expect(err.Stack[0].Line).To(BeNumerically(">", 0)) - Expect(err.Stack[0].Function).NotTo(BeEmpty()) - Expect(err.Stack[0].Timestamp).NotTo(BeZero()) - }) - - It("should allow multiple stack traces", func() { - e := From(ErrForbidden).WithIdentifier(403001) - err1 := Intercept(e.Throw()) - err2 := Intercept(Stamp(err1)) - Expect(len(err2.Stack)).To(Equal(2)) + Context("When wrapping nil", func() { + It("should return unknown error with nil cause", func() { + e = Wrap(nil) + var err *Error + Expect(errors.As(e, &err)).To(BeTrue()) + Expect(err.Title).To(Equal("unknown error")) + Expect(err.Cause).To(BeNil()) }) }) - Context("When using WithProperties() method", func() { - It("should add multiple properties at once", func() { - props := map[string]any{ - "User": "john.doe", - "Action": "write", - "File": "test.txt", - } - e := From(ErrForbidden).WithProperties(props) - Expect(e.Properties).To(Equal(props)) + Context("When comparing errors with Is()", func() { + It("should return true for errors with same title and identifier", func() { + e1 = Wrap(ErrForbidden, WithIdentifier(403001)) + e2 = Wrap(ErrForbidden, WithIdentifier(403001)) + Expect(Is(e1, e2)).To(BeTrue()) }) - It("should merge with existing properties", func() { - e := From(ErrForbidden). - WithProperty("User", "john.doe"). - WithProperties(map[string]any{ - "Action": "write", - "File": "test.txt", - }) - Expect(e.Properties).To(HaveLen(3)) - Expect(e.Properties["User"]).To(Equal("john.doe")) - Expect(e.Properties["Action"]).To(Equal("write")) - Expect(e.Properties["File"]).To(Equal("test.txt")) + It("should return false for errors with different titles", func() { + e1 = Wrap(ErrForbidden, WithIdentifier(403001)) + e2 = Wrap(ErrNotFound, WithIdentifier(403001)) + Expect(Is(e1, e2)).To(BeFalse()) }) - It("should overwrite existing property with same key", func() { - e := From(ErrForbidden). - WithProperty("User", "john.doe"). - WithProperty("User", "jane.doe") - Expect(e.Properties["User"]).To(Equal("jane.doe")) + It("should return false for errors with different identifiers", func() { + e1 = Wrap(ErrForbidden, WithIdentifier(403001)) + e2 = Wrap(ErrForbidden, WithIdentifier(403002)) + Expect(Is(e1, e2)).To(BeFalse()) }) - }) - Context("When using WithDetailf() method", func() { - It("should format detail string with arguments", func() { - e := From(ErrNotFound). - WithDetailf("File '%s' not found in directory '%s'", "test.txt", "/home/user") - Expect(e.Details).To(HaveLen(1)) - Expect(e.Details[0]).To(Equal("File 'test.txt' not found in directory '/home/user'")) + It("should return false for standard errors", func() { + e := Wrap(ErrForbidden, WithIdentifier(403001)) + Expect(Is(e, errTest)).To(BeFalse()) }) - It("should format detail with multiple format specifiers", func() { - e := From(ErrForbidden). - WithDetailf("User %s attempted to access resource %d at %s", "john.doe", 12345, "2023-10-15") - Expect(e.Details).To(HaveLen(1)) - Expect(e.Details[0]).To(Equal("User john.doe attempted to access resource 12345 at 2023-10-15")) + It("should work with errors.Is() from standard library", func() { + e1 = Wrap(ErrForbidden, WithIdentifier(403001)) + e2 = Wrap(ErrForbidden, WithIdentifier(403001)) + Expect(errors.Is(e1, e2)).To(BeTrue()) }) - It("should trim trailing period from formatted detail", func() { - e := From(ErrNotFound). - WithDetailf("Resource with ID %d not found.", 42) - Expect(e.Details[0]).To(Equal("Resource with ID 42 not found")) + It("should return false when target is nil", func() { + e := Wrap(ErrForbidden, WithIdentifier(403001)) + Expect(Is(e, nil)).To(BeFalse()) }) - It("should chain with other methods", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetailf("User %s lacks permission", "john.doe"). - WithDetail("Admin role required"). - WithProperty("User", "john.doe") - Expect(e.Details).To(Equal([]string{"User john.doe lacks permission", "Admin role required"})) - Expect(e.Properties["User"]).To(Equal("john.doe")) + It("should return false when error is nil", func() { + e := Wrap(ErrForbidden, WithIdentifier(403001)) + Expect(Is(nil, e)).To(BeFalse()) }) - It("should handle format string without arguments", func() { - e := From(ErrInternal).WithDetailf("An unexpected error occurred") - Expect(e.Details[0]).To(Equal("An unexpected error occurred")) + It("should return true for wrapped errors with same title and identifier", func() { + e1_1 := Wrap(ErrForbidden, WithIdentifier(403001)) + e1_2 := Wrap(e1_1, WithIdentifier(403002)) + e2_1 := Wrap(ErrForbidden, WithIdentifier(403001)) + e2_2 := Wrap(e2_1, WithIdentifier(403002)) + Expect(Is(e1_2, e2_2)).To(BeTrue()) }) - }) - Context("When using Wrap() function", func() { - It("should wrap a standard error with a message", func() { - wrapped := Wrap(errTest, "failed to process request") - e := Intercept(wrapped) - Expect(e.Title).To(Equal("unknown error")) - Expect(e.Details).To(Equal([]string{"failed to process request"})) - Expect(e.Cause).To(Equal(errTest)) - Expect(e.Stack).NotTo(BeEmpty()) - }) - - It("should wrap a custom Error with a message", func() { - original := From(ErrNotFound).WithIdentifier(404001) - wrapped := Wrap(original, "resource lookup failed") - e := Intercept(wrapped) - Expect(e.Title).To(Equal("not found")) - Expect(e.Details).To(Equal([]string{"resource lookup failed"})) - Expect(e.Cause).To(BeNil()) // From() on *Error doesn't set cause - Expect(e.Stack).NotTo(BeEmpty()) - }) - - It("should add stack trace automatically", func() { - wrapped := Wrap(errPerm, "authentication failed") - e := Intercept(wrapped) - Expect(e.Stack).To(HaveLen(1)) - Expect(e.Stack[0].File).NotTo(BeEmpty()) - Expect(e.Stack[0].Line).To(BeNumerically(">", 0)) - Expect(e.Stack[0].Function).NotTo(BeEmpty()) - Expect(e.Stack[0].Timestamp).NotTo(BeZero()) - }) - - It("should trim trailing period from message", func() { - wrapped := Wrap(errTest, "operation failed.") - e := Intercept(wrapped) - Expect(e.Details[0]).To(Equal("operation failed")) - }) - - It("should allow chaining multiple wraps", func() { - err1 := Wrap(errTest, "database query failed") - err2 := Wrap(err1, "user service error") - e := Intercept(err2) - // From() only preserves title, so only the latest detail is kept - Expect(e.Details).To(Equal([]string{"database query failed", "user service error"})) - Expect(e.Stack).To(HaveLen(2)) - // From() on *Error doesn't set cause - Expect(e.Cause).To(BeNil()) - }) - - It("should preserve error title when wrapping custom errors", func() { - original := From(ErrForbidden). - WithIdentifier(403001). - WithProperty("User", "john.doe") - wrapped := Wrap(original, "access denied") - e := Intercept(wrapped) - Expect(e.Title).To(Equal("forbidden")) - Expect(e.Identifier).To(Equal(int32(403001))) - Expect(e.Details).To(Equal([]string{"access denied"})) - Expect(e.Properties).To(Equal(map[string]any{"User": "john.doe"})) + It("should return false for wrapped errors with different identifier", func() { + e1_1 := Wrap(ErrForbidden, WithIdentifier(403001)) + e1_2 := Wrap(e1_1, WithIdentifier(403002)) + e2_1 := Wrap(ErrForbidden, WithIdentifier(403001)) + e2_2 := Wrap(e2_1, WithIdentifier(403003)) + Expect(Is(e1_2, e2_2)).To(BeFalse()) }) - }) - Context("When using Wrapf() function", func() { - It("should wrap an error with a formatted message", func() { - wrapped := Wrapf(errTest, "failed to process request for user %s", "john.doe") - e := Intercept(wrapped) - Expect(e.Title).To(Equal("unknown error")) - Expect(e.Details).To(Equal([]string{"failed to process request for user john.doe"})) - Expect(e.Cause).To(Equal(errTest)) - Expect(e.Stack).NotTo(BeEmpty()) + It("should return true for error and its child", func() { + e1_1 := Wrap(ErrForbidden, WithIdentifier(1)) + e1_2 := Wrap(e1_1, WithIdentifier(2)) + e2_1 := Wrap(ErrForbidden, WithIdentifier(1)) + e2_2 := Wrap(e2_1, WithIdentifier(2)) + e2_3 := Wrap(e2_2, WithIdentifier(3)) + Expect(Is(e2_3, e1_2)).To(BeTrue()) + Expect(Is(e1_2, e2_3)).To(BeFalse()) }) - It("should format message with multiple arguments", func() { - wrapped := Wrapf(errPerm, "user %s failed to access file %s at line %d", "alice", "config.yaml", 42) - e := Intercept(wrapped) - Expect(e.Details[0]).To(Equal("user alice failed to access file config.yaml at line 42")) + It("should return false for error and its parent", func() { + e1_1 := Wrap(ErrForbidden, WithIdentifier(2)) + e1_2 := Wrap(e1_1, WithIdentifier(3)) + e2_1 := Wrap(ErrForbidden, WithIdentifier(1)) + e2_2 := Wrap(e2_1, WithIdentifier(2)) + e2_3 := Wrap(e2_2, WithIdentifier(3)) + Expect(Is(e2_3, e1_2)).To(BeFalse()) }) + }) - It("should handle format with no arguments", func() { - wrapped := Wrapf(errTest, "an error occurred") - e := Intercept(wrapped) - Expect(e.Details[0]).To(Equal("an error occurred")) + Context("When unwrapping errors", func() { + It("should return the cause when present", func() { + e := Wrap(ErrForbidden, CausedBy(errPerm)) + Expect(Unwrap(e)).To(Equal(errPerm)) }) - It("should add stack trace automatically", func() { - wrapped := Wrapf(errTest, "operation failed with code %d", 500) - e := Intercept(wrapped) - Expect(e.Stack).To(HaveLen(1)) - Expect(e.Stack[0].File).NotTo(BeEmpty()) - Expect(e.Stack[0].Timestamp).NotTo(BeZero()) + It("should return the cause when error is a standard one", func() { + err := errors.New("test error") + e := Wrap(err) + Expect(Unwrap(e)).To(Equal(err)) }) - It("should trim trailing period from formatted message", func() { - wrapped := Wrapf(errTest, "failed with error code %d.", 404) - e := Intercept(wrapped) - Expect(e.Details[0]).To(Equal("failed with error code 404")) + It("should return nil when no cause", func() { + e := Wrap(ErrForbidden) + Expect(Unwrap(e)).To(BeNil()) }) - It("should allow chaining with Wrap", func() { - err1 := Wrapf(errTest, "database error: code %d", 1062) - err2 := Wrap(err1, "duplicate entry detected") - e := Intercept(err2) - Expect(e.Details).To(Equal([]string{"database error: code 1062", "duplicate entry detected"})) - Expect(e.Stack).To(HaveLen(2)) - Expect(e.Cause).To(BeNil()) + It("should return nil for nil error", func() { + var e *Error + Expect(Unwrap(e)).To(BeNil()) }) - It("should handle complex format patterns", func() { - wrapped := Wrapf(errTest, "failed to connect to %s:%d (timeout: %v)", "localhost", 8080, true) - e := Intercept(wrapped) - Expect(e.Details[0]).To(Equal("failed to connect to localhost:8080 (timeout: true)")) + It("should return nil when error has no cause", func() { + e := Wrap(ErrNotFoundWithOptions) + Expect(Unwrap(e)).To(BeNil()) }) - It("should work with custom errors", func() { - original := From(ErrInternal).WithIdentifier(500001) - wrapped := Wrapf(original, "service %s returned error %d", "auth-service", 500) - e := Intercept(wrapped) - Expect(e.Title).To(Equal("internal error")) - Expect(e.Identifier).To(Equal(int32(500001))) - Expect(e.Details).To(Equal([]string{"service auth-service returned error 500"})) + It("should work with errors.Unwrap() from standard library", func() { + e := Wrap(ErrForbidden, CausedBy(errPerm)) + Expect(errors.Unwrap(e)).To(Equal(errPerm)) }) }) - Context("When using package-level As() function", func() { - It("should convert error to target type", func() { - e := From(ErrForbidden).WithIdentifier(403001) + Context("When using As()", func() { + It("should return true when error is *Error", func() { + e := Wrap(ErrForbidden, WithIdentifier(403001)) var target *Error - Expect(As(e, &target)).To(BeTrue()) - Expect(target.Title).To(Equal("forbidden")) - Expect(target.Identifier).To(Equal(int32(403001))) + ok := As(e, &target) + Expect(ok).To(BeTrue()) }) - It("should return false when error is not in the chain", func() { - // Create a standard error that won't convert to *Error - standardErr := errors.New("standard error") + It("should return true when error wraps *Error", func() { + inner := Wrap(ErrNotFound, WithIdentifier(404001)) + e := Wrap(inner, WithDetail("file missing")) var target *Error - Expect(As(standardErr, &target)).To(BeFalse()) - Expect(target).To(BeNil()) + ok := As(e, &target) + Expect(ok).To(BeTrue()) }) - It("should work with wrapped errors", func() { - innerErr := From(ErrNotFound).WithIdentifier(404001) - outerErr := From(ErrForbidden).CausedBy(innerErr) + It("should return false for nil error", func() { var target *Error - Expect(As(outerErr, &target)).To(BeTrue()) - Expect(target.Title).To(Equal("forbidden")) - }) - - It("should extract standard errors from cause chain", func() { - e := From(ErrForbidden).CausedBy(errPerm) - var standardErr error - // Should be able to extract the error itself - Expect(As(e, &standardErr)).To(BeTrue()) - }) - }) - - Context("When intercepting standard errors", func() { - It("should convert standard error to Error type", func() { - e := Intercept(errTest) - Expect(e).To(BeAssignableToTypeOf(&Error{})) - Expect(e.Title).To(Equal("unknown error")) - Expect(e.Cause).To(Equal(errTest)) - }) - - It("should return same error if already Error type", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := Intercept(e1) - Expect(e2).To(Equal(e1)) - }) - }) - - Context("Edge cases", func() { - It("should handle error without identifier", func() { - e := From(ErrNotFound).WithDetail("resource not found") - Expect(e.Identifier).To(BeZero()) - result := e.Error() - Expect(result).To(ContainSubstring("not found (0)")) - }) - - It("should trim trailing period from details", func() { - e := From(ErrNotFound).WithDetail("File not found.") - Expect(e.Details[0]).To(Equal("File not found")) - }) - - It("should handle error with no details", func() { - e := From(ErrNotFound).WithIdentifier(404001) - result := e.Error() - Expect(result).To(Equal("not found (404001)")) - }) - - It("should handle error with no properties", func() { - e := From(ErrNotFound).WithIdentifier(404001).WithDetail("resource not found") - result := e.Error() - Expect(result).NotTo(ContainSubstring("=")) - }) - - It("should handle error with no cause", func() { - e := From(ErrNotFound) - Expect(e.Cause).To(BeNil()) - result := e.Error() - Expect(result).NotTo(ContainSubstring("caused by")) - }) - - It("should handle nil error in Error() method", func() { - var e *Error - Expect(e.Error()).To(Equal("")) - }) - - It("should preserve order of details when wrapping", func() { - e1 := From(ErrForbidden). - WithDetail("first detail"). - WithDetail("second detail") - e2 := Intercept(e1).WithDetail("third detail") - Expect(e2.Details).To(Equal([]string{"first detail", "second detail", "third detail"})) - }) - - It("should handle multiple stack traces in correct order", func() { - e := From(ErrInternal).WithIdentifier(500001) - err1 := Intercept(e.Throw()) - err2 := Intercept(Stamp(err1)) - err3 := Intercept(Stamp(err2)) - Expect(len(err3.Stack)).To(Equal(3)) - // Most recent should be first - Expect(err3.Stack[0].Timestamp.After(err3.Stack[1].Timestamp)).To(BeTrue()) - Expect(err3.Stack[1].Timestamp.After(err3.Stack[2].Timestamp)).To(BeTrue()) - }) - }) - - Context("When using New() function", func() { - It("should create a new error with given title", func() { - e := New("test error") - Expect(e).NotTo(BeNil()) - Expect(e.Error()).To(Equal("test error (0)")) - }) - - It("should return error interface", func() { - err := New("test error") - Expect(err).NotTo(BeNil()) - }) - - It("should create error with empty fields", func() { - e := New("simple error") - err := Intercept(e) - Expect(err.Title).To(Equal("simple error")) - Expect(err.Identifier).To(BeZero()) - Expect(err.Details).To(BeEmpty()) - Expect(err.Properties).To(BeEmpty()) - Expect(err.Cause).To(BeNil()) - Expect(err.Stack).To(BeEmpty()) - }) - }) - - Context("When using standalone methods", func() { - It("should set identifier with WithIdentifier()", func() { - e := New("test error") - err := Intercept(e).WithIdentifier(12345) - Expect(err.Identifier).To(Equal(int32(12345))) - }) - - It("should replace identifier when called multiple times", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithIdentifier(403002) - Expect(e.Identifier).To(Equal(int32(403002))) + ok := As(nil, &target) + Expect(ok).To(BeFalse()) }) - It("should set cause with CausedBy()", func() { - e := New("test error") - err := Intercept(e).CausedBy(errTest) - Expect(err.Cause).To(Equal(errTest)) - }) - - It("should replace cause when called multiple times", func() { - e := From(ErrForbidden). - CausedBy(errTest). - CausedBy(errPerm) - Expect(e.Cause).To(Equal(errPerm)) - }) - - It("should add single property with WithProperty()", func() { - e := New("test error") - err := Intercept(e).WithProperty("key", "value") - Expect(err.Properties).To(HaveLen(1)) - Expect(err.Properties["key"]).To(Equal("value")) - }) - - It("should handle nil properties map", func() { - e := &Error{Title: "test"} - Expect(e.Properties).To(BeNil()) - e.WithProperty("key", "value") // nolint:errcheck // No way to get wrong here. - Expect(e.Properties).NotTo(BeNil()) - Expect(e.Properties["key"]).To(Equal("value")) - }) - }) - - Context("When using From() with edge cases", func() { - It("should handle nil error", func() { - e := From(nil) - Expect(e).NotTo(BeNil()) - Expect(e.Title).To(Equal("unknown error")) - Expect(e.Cause).To(BeNil()) - }) - - It("should copy all fields from Error type", func() { - original := &Error{ - Title: "test error", - Identifier: 123, - Details: []string{"detail1", "detail2"}, - Properties: map[string]any{"key": "value"}, - Cause: errTest, - Stack: []*Trace{{Function: "test"}}, - } - e := From(original) - Expect(e.Title).To(Equal("test error")) - Expect(e.Identifier).To(Equal(int32(123))) - Expect(e.Details).To(Equal([]string{"detail1", "detail2"})) - Expect(e.Properties).To(Equal(map[string]any{"key": "value"})) - Expect(e.Cause).To(BeNil()) // From() doesn't copy cause from *Error - Expect(e.Stack).To(BeEmpty()) // From() doesn't copy stack - }) - - It("should create new instance, not reference", func() { - original := From(ErrForbidden) - copy := From(original) - copy.WithDetail("new detail") // nolint:errcheck // No way to get wrong here. - Expect(original.Details).To(BeEmpty()) - Expect(copy.Details).To(HaveLen(1)) - }) - }) - - Context("When using Intercept() with edge cases", func() { - It("should handle nil error", func() { - e := Intercept(nil) - Expect(e).NotTo(BeNil()) - Expect(e.Title).To(Equal("unknown error")) - }) - - It("should return same instance for *Error type", func() { - original := From(ErrForbidden).WithIdentifier(403001) - intercepted := Intercept(original) - Expect(intercepted).To(BeIdenticalTo(original)) - }) - - It("should handle multiple consecutive Intercepts", func() { - e1 := Intercept(errTest) - e2 := Intercept(e1) - e3 := Intercept(e2) - Expect(e1).To(BeIdenticalTo(e2)) - Expect(e2).To(BeIdenticalTo(e3)) - }) - }) - - Context("When using Stamp() with edge cases", func() { - It("should add stack trace to standard error", func() { - stamped := Stamp(errTest) - e := Intercept(stamped) - Expect(e.Stack).To(HaveLen(1)) - Expect(e.Stack[0].File).NotTo(BeEmpty()) - Expect(e.Stack[0].Line).To(BeNumerically(">", 0)) - }) - - It("should preserve existing error information", func() { - original := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("test detail"). - WithProperty("key", "value") - stamped := Stamp(original) - e := Intercept(stamped) - Expect(e.Title).To(Equal("forbidden")) - Expect(e.Identifier).To(Equal(int32(403001))) - Expect(e.Details).To(Equal([]string{"test detail"})) - Expect(e.Properties).To(Equal(map[string]any{"key": "value"})) - Expect(e.Stack).To(HaveLen(1)) - }) - - It("should allow multiple stamps", func() { - e := From(ErrInternal) - e1 := Intercept(Stamp(e)) - e2 := Intercept(Stamp(e1)) - e3 := Intercept(Stamp(e2)) - Expect(e3.Stack).To(HaveLen(3)) - }) - }) - - Context("Error formatting edge cases", func() { - It("should format error with multiple properties", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithProperty("User", "john"). - WithProperty("File", "test.txt"). - WithProperty("Action", "write") - result := e.Error() - Expect(result).To(ContainSubstring("forbidden (403001)")) - Expect(result).To(ContainSubstring("User=")) - Expect(result).To(ContainSubstring("File=")) - Expect(result).To(ContainSubstring("Action=")) - }) - - It("should handle empty detail strings", func() { - e := From(ErrNotFound).WithDetail("") - Expect(e.Details).To(Equal([]string{""})) - }) - - It("should format error with only identifier and no title", func() { - e := &Error{Identifier: 123} - result := e.Error() - Expect(result).To(Equal(" (123)")) - }) - - It("should handle error with nil cause", func() { - e := &Error{Title: "test", Cause: nil} - result := e.Error() - Expect(result).NotTo(ContainSubstring("caused by")) - }) - - It("should format stack trace correctly", func() { - e := From(ErrForbidden).WithIdentifier(403001) - stamped := Stamp(e) - result := stamped.Error() - Expect(result).To(ContainSubstring("at=(")) - Expect(result).To(ContainSubstring("func=")) - Expect(result).To(ContainSubstring("file=")) - Expect(result).To(ContainSubstring("line=")) - }) - }) - - Context("Method chaining edge cases", func() { - It("should handle long method chains", func() { - e := From(ErrForbidden). - WithIdentifier(403001). - WithDetail("detail 1"). - WithDetail("detail 2"). - WithDetail("detail 3"). - WithProperty("prop1", "val1"). - WithProperty("prop2", "val2"). - WithProperty("prop3", "val3"). - CausedBy(errPerm) - Expect(e.Title).To(Equal("forbidden")) - Expect(e.Identifier).To(Equal(int32(403001))) - Expect(e.Details).To(HaveLen(3)) - Expect(e.Properties).To(HaveLen(3)) - Expect(e.Cause).To(Equal(errPerm)) - }) - - It("should maintain detail order in chains", func() { - e := From(ErrNotFound). - WithDetail("first"). - WithDetail("second"). - WithDetail("third") - Expect(e.Details[0]).To(Equal("first")) - Expect(e.Details[1]).To(Equal("second")) - Expect(e.Details[2]).To(Equal("third")) - }) - - It("should allow mixing formatted and regular details", func() { - e := From(ErrForbidden). - WithDetail("regular detail"). - WithDetailf("formatted %s", "detail"). - WithDetail("another regular") - Expect(e.Details).To(Equal([]string{"regular detail", "formatted detail", "another regular"})) - }) - }) - - Context("Integration with standard errors package", func() { - It("should work with errors.As()", func() { - e := From(ErrForbidden).WithIdentifier(403001) + It("should return false when error is not an *Error", func() { + e := errTest var target *Error - Expect(errors.As(e, &target)).To(BeTrue()) - Expect(target.Title).To(Equal("forbidden")) - Expect(target.Identifier).To(Equal(int32(403001))) - }) - - It("should work with errors.Is() through wrapped errors with cause", func() { - e1 := From(ErrForbidden).WithIdentifier(403001) - e2 := From(ErrInternal).CausedBy(e1) - // e2 wraps e1 as its cause, so errors.Is should find e1 - Expect(errors.Is(e2, e1)).To(BeTrue()) + ok := As(e, &target) + Expect(ok).To(BeFalse()) + }) + + It("should match standard library errors.As behavior", func() { + e := Wrap(ErrForbidden, CausedBy(errPerm)) + var pkgTarget *Error + var stdTarget *Error + pkgOk := As(e, &pkgTarget) + stdOk := errors.As(e, &stdTarget) + Expect(pkgOk).To(BeTrue()) + Expect(stdOk).To(BeTrue()) }) }) })