Skip to content

Commit

Permalink
Merge pull request #416 from projectdiscovery/feat-errkit
Browse files Browse the repository at this point in the history
Feat errkit
  • Loading branch information
tarunKoyalwar committed May 22, 2024
2 parents d807fbc + 389764f commit 10ef59b
Show file tree
Hide file tree
Showing 7 changed files with 1,059 additions and 0 deletions.
58 changes: 58 additions & 0 deletions errkit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# errkit

why errkit when we already have errorutil ?

----

Introduced a year ago, `errorutil` aimed to capture error stacks for identifying deeply nested errors. However, its approach deviates from Go's error handling paradigm. In Go, libraries like "errors", "pkg/errors", and "uber.go/multierr" avoid using the `.Error()` method directly. Instead, they wrap errors with helper structs that implement specific interfaces, facilitating error chain traversal and the use of helper functions like `.Cause() error` or `.Unwrap() error` or `errors.Is()`. Contrarily, `errorutil` marshals errors to strings, which is incompatible with Go's error handling paradigm. Over time, the use of `errorutil` has become cumbersome due to its inability to replace any error package seamlessly and its lack of support for idiomatic error propagation or traversal in Go.


`errkit` is a new error library that addresses the shortcomings of `errorutil`. It offers the following features:

- Seamless replacement for existing error packages, requiring no syntax changes or refactoring:
- `errors` package
- `pkg/errors` package (now deprecated)
- `uber/multierr` package
- `errkit` is compatible with all known Go error handling implementations. It can parse errors from any library and works with existing error handling libraries and helper functions like `Is()`, `As()`, `Cause()`, and more.
- `errkit` is Go idiomatic and adheres to the Go error handling paradigm.
- `errkit` supports attributes for structured error information or logging using `slog.Attr` (optional).
- `errkit` implements and categorizes errors into different kinds, as detailed below.
- `ErrKindNetworkTemporary`
- `ErrKindNetworkPermanent`
- `ErrKindDeadline`
- Custom kinds via `ErrKind` interface
- `errkit` provides helper functions for structured error logging using `SlogAttrs` and `SlogAttrGroup`.
- `errkit` offers helper functions to implement public or user-facing errors by using error kinds interface.


**Attributes Support**

`errkit` supports optional error wrapping with attributes `slog.Attr` for structured error logging, providing a more organized approach to error logging than string wrapping.

```go
// normal way of error propogating through nested stack
err := errkit.New("i/o timeout")

// xyz.go
err := errkit.Wrap(err,"failed to connect %s",addr)

// abc.go
err := errkit.Wrap(err,"error occured when downloading %s",xyz)
```

with attributes support you can do following

```go
// normal way of error propogating through nested stack
err := errkit.New("i/o timeout")

// xyz.go
err = errkit.WithAttr(err,slog.Any("resource",domain))

// abc.go
err = errkit.WithAttr(err,slog.Any("action","download"))
```

## Note

To keep errors concise and avoid unnecessary allocations, message wrapping and attributes count have a max depth set to 3. Adding more will not panic but will be simply ignored. This is configurable using the MAX_ERR_DEPTH env variable (default 3).
278 changes: 278 additions & 0 deletions errkit/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
// errkit implements all errors generated by nuclei and includes error definations
// specific to nuclei , error classification (like network,logic) etc
package errkit

import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"

"github.com/projectdiscovery/utils/env"
"golang.org/x/exp/maps"
)

const (
// DelimArrow is delim used by projectdiscovery/utils to join errors
DelimArrow = "<-"
// DelimArrowSerialized
DelimArrowSerialized = "\u003c-"
// DelimSemiColon is standard delim popularly used to join errors
DelimSemiColon = "; "
// DelimMultiLine is delim used to join errors in multiline format
DelimMultiLine = "\n - "
// MultiLinePrefix is the prefix used for multiline errors
MultiLineErrPrefix = "the following errors occurred:"
)

var (
// MaxErrorDepth is the maximum depth of errors to be unwrapped or maintained
// all errors beyond this depth will be ignored
MaxErrorDepth = env.GetEnvOrDefault("MAX_ERROR_DEPTH", 3)
// ErrorSeperator is the seperator used to join errors
ErrorSeperator = env.GetEnvOrDefault("ERROR_SEPERATOR", "; ")
)

// ErrorX is a custom error type that can handle all known types of errors
// wrapping and joining strategies including custom ones and it supports error class
// which can be shown to client/users in more meaningful way
type ErrorX struct {
kind ErrKind
attrs map[string]slog.Attr
errs []error
uniqErrs map[string]struct{}
}

// append is internal method to append given
// error to error slice , it removes duplicates
func (e *ErrorX) append(errs ...error) {
if e.uniqErrs == nil {
e.uniqErrs = make(map[string]struct{})
}
for _, err := range errs {
if _, ok := e.uniqErrs[err.Error()]; ok {
continue
}
e.uniqErrs[err.Error()] = struct{}{}
e.errs = append(e.errs, err)
}
}

// Errors returns all errors parsed by the error
func (e *ErrorX) Errors() []error {
return e.errs
}

// Attrs returns all attributes associated with the error
func (e *ErrorX) Attrs() []slog.Attr {
if e.attrs == nil {
return nil
}
return maps.Values(e.attrs)
}

// Build returns the object as error interface
func (e *ErrorX) Build() error {
return e
}

// Unwrap returns the underlying error
func (e *ErrorX) Unwrap() []error {
return e.errs
}

// Is checks if current error contains given error
func (e *ErrorX) Is(err error) bool {
x := &ErrorX{}
parseError(x, err)
// even one submatch is enough
for _, orig := range e.errs {
for _, match := range x.errs {
if errors.Is(orig, match) {
return true
}
}
}
return false
}

// MarshalJSON returns the json representation of the error
func (e *ErrorX) MarshalJSON() ([]byte, error) {
m := map[string]interface{}{
"kind": e.kind.String(),
"errors": e.errs,
}
if len(e.attrs) > 0 {
m["attrs"] = slog.GroupValue(maps.Values(e.attrs)...)
}
return json.Marshal(m)
}

// Error returns the error string
func (e *ErrorX) Error() string {
var sb strings.Builder
if e.kind != nil && e.kind.String() != "" {
sb.WriteString("errKind=")
sb.WriteString(e.kind.String())
sb.WriteString(" ")
}
if len(e.attrs) > 0 {
sb.WriteString(slog.GroupValue(maps.Values(e.attrs)...).String())
sb.WriteString(" ")
}
for _, err := range e.errs {
sb.WriteString(err.Error())
sb.WriteString(ErrorSeperator)
}
return strings.TrimSuffix(sb.String(), ErrorSeperator)
}

// Cause return the original error that caused this without any wrapping
func (e *ErrorX) Cause() error {
if len(e.errs) > 0 {
return e.errs[0]
}
return nil
}

// Kind returns the errorkind associated with this error
// if any
func (e *ErrorX) Kind() ErrKind {
if e.kind == nil || e.kind.String() == "" {
return ErrKindUnknown
}
return e.kind
}

// FromError parses a given error to understand the error class
// and optionally adds given message for more info
func FromError(err error) *ErrorX {
if err == nil {
return nil
}
nucleiErr := &ErrorX{}
parseError(nucleiErr, err)
return nucleiErr
}

// New creates a new error with the given message
func New(format string, args ...interface{}) *ErrorX {
return &ErrorX{errs: []error{fmt.Errorf(format, args...)}}
}

// Msgf adds a message to the error
func (e *ErrorX) Msgf(format string, args ...interface{}) {
if e == nil {
return
}
e.append(fmt.Errorf(format, args...))
}

// SetClass sets the class of the error
// if underlying error class was already set, then it is given preference
// when generating final error msg
func (e *ErrorX) SetKind(kind ErrKind) *ErrorX {
if e.kind == nil {
e.kind = kind
} else {
e.kind = CombineErrKinds(e.kind, kind)
}
return e
}

// SetAttr sets additional attributes to a given error
// it only adds unique attributes and ignores duplicates
// Note: only key is checked for uniqueness
func (e *ErrorX) SetAttr(s ...slog.Attr) *ErrorX {
for _, attr := range s {
if e.attrs == nil {
e.attrs = make(map[string]slog.Attr)
}
// check if this exists
if _, ok := e.attrs[attr.Key]; !ok && len(e.attrs) < MaxErrorDepth {
e.attrs[attr.Key] = attr
}
}
return e
}

// parseError recursively parses all known types of errors
func parseError(to *ErrorX, err error) {
if err == nil {
return
}
if to == nil {
to = &ErrorX{}
}
if len(to.errs) >= MaxErrorDepth {
return
}

switch v := err.(type) {
case *ErrorX:
to.append(v.errs...)
to.kind = CombineErrKinds(to.kind, v.kind)
case JoinedError:
foundAny := false
for _, e := range v.Unwrap() {
to.append(e)
foundAny = true
}
if !foundAny {
parseError(to, errors.New(err.Error()))
}
case WrappedError:
if v.Unwrap() != nil {
parseError(to, v.Unwrap())
} else {
parseError(to, errors.New(err.Error()))
}
case CauseError:
to.append(v.Cause())
remaining := strings.Replace(err.Error(), v.Cause().Error(), "", -1)
parseError(to, errors.New(remaining))
default:
errString := err.Error()
// try assigning to enriched error
if strings.Contains(errString, DelimArrow) {
// Split the error by arrow delim
parts := strings.Split(errString, DelimArrow)
for i := len(parts) - 1; i >= 0; i-- {
part := strings.TrimSpace(parts[i])
parseError(to, errors.New(part))
}
} else if strings.Contains(errString, DelimArrowSerialized) {
// Split the error by arrow delim
parts := strings.Split(errString, DelimArrowSerialized)
for i := len(parts) - 1; i >= 0; i-- {
part := strings.TrimSpace(parts[i])
parseError(to, errors.New(part))
}
} else if strings.Contains(errString, DelimSemiColon) {
// Split the error by semi-colon delim
parts := strings.Split(errString, DelimSemiColon)
for _, part := range parts {
part = strings.TrimSpace(part)
parseError(to, errors.New(part))
}
} else if strings.Contains(errString, MultiLineErrPrefix) {
// remove prefix
msg := strings.ReplaceAll(errString, MultiLineErrPrefix, "")
parts := strings.Split(msg, DelimMultiLine)
for _, part := range parts {
part = strings.TrimSpace(part)
parseError(to, errors.New(part))
}
} else {
// this cannot be furthur unwrapped
to.append(err)
}
}
}

// WrappedError is implemented by errors that are wrapped
type WrappedError interface {
// Unwrap returns the underlying error
Unwrap() error
}
Loading

0 comments on commit 10ef59b

Please sign in to comment.