Documentation
¶
Overview ¶
Package errx gives errors the parts that their readers need: a kind and a code for your program, a message and field errors for clients, and details and a short trace for logs. It has no dependencies outside the standard library.
Errors from this package are ordinary errors: errors.Is, errors.As and errors.Unwrap see through them.
Creating and wrapping ¶
New creates an error, and options set its parts:
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
On the way up, Wrap records where the error passed and keeps its parts. Newf with %w does the same and also adds words to the message:
return errx.Wrap(err, errx.WithDetails(errx.Details{"user_id": id}))
return errx.Newf("load profile %d: %w", id, err)
Prefer them to fmt.Errorf: when an error from fmt.Errorf is on the outside, %+v and logs show only its message.
Checking ¶
KindOf and CodeOf find the first *Error in a chain, so programs can decide what to do. To read every part, use errors.As:
var e *errx.Error
if errors.As(err, &e) {
fmt.Println(e.Code(), e.FieldErrors())
}
Field errors and details ¶
FieldErrors tell clients what is wrong with each input field. Details and the trace are for logs only; do not send them to clients.
Logging ¶
*Error implements slog.LogValuer, so slog.Any("err", err) logs every part as a group. Attr does the same for an error that other code wrapped with fmt.Errorf. The %+v verb prints every part for people.
Sending errors to clients and other services ¶
errx has no transport code. Send the message, code, kind and field errors in your own format, and rebuild the error with New on the other side. A Kind encodes as text, such as "not_found". Send the message only for client errors, such as KindValidation or KindNotFound: the messages of other errors may contain private data.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func findUser(id int) error {
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}
func main() {
err := findUser(42)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrUserNotFound))
fmt.Println(errx.KindOf(err), errx.CodeOf(err))
}
Output: user not found true not_found USER_NOT_FOUND
Index ¶
- func Attr(key string, err error) slog.Attr
- func CodeOf(err error) string
- func New(msg string, opts ...Option) error
- func Newf(format string, args ...any) error
- func Wrap(err error, opts ...Option) error
- type Details
- type Error
- func (e *Error) Code() string
- func (e *Error) Details() Details
- func (e *Error) Error() string
- func (e *Error) FieldErrors() FieldErrors
- func (e *Error) Format(s fmt.State, verb rune)
- func (e *Error) Kind() Kind
- func (e *Error) LogValue() slog.Value
- func (e *Error) Trace() []Frame
- func (e *Error) Unwrap() error
- type FieldErrors
- type Frame
- type Kind
- type Option
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Attr ¶ added in v1.0.0
Attr returns a slog attribute for err. Unlike slog.Any, it finds the first *Error in err's chain, so the code, kind, field errors, details and trace are logged even when err was wrapped with fmt.Errorf. The message is err.Error(). If the chain has no *Error, Attr returns slog.Any(key, err). If err is nil, including a nil *Error, Attr returns slog.Any(key, nil).
Example ¶
package main
import (
"fmt"
"log/slog"
"os"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func findUser(id int) error {
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}
// stableOutput drops the time and the trace from log records, so the
// examples have the same output on every machine.
func stableOutput(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey || a.Key == "trace" {
return slog.Attr{}
}
return a
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: stableOutput}))
// Another package added context with fmt.Errorf.
err := fmt.Errorf("handle request: %w", findUser(42))
logger.Error("request failed", errx.Attr("err", err))
}
Output: {"level":"ERROR","msg":"request failed","err":{"message":"handle request: user not found","kind":"not_found","code":"USER_NOT_FOUND","details":{"user_id":42}}}
func CodeOf ¶ added in v1.0.0
CodeOf returns the code of the first *Error in err's chain, or "" if there is none.
func New ¶
New returns a new error with the given message and options.
Example ¶
package main
import (
"fmt"
"github.com/code19m/errx"
)
func main() {
err := errx.New("email is already taken",
errx.WithCode("EMAIL_TAKEN"),
errx.WithKind(errx.KindConflict),
)
fmt.Println(err, errx.KindOf(err), errx.CodeOf(err))
}
Output: email is already taken conflict EMAIL_TAKEN
func Newf ¶ added in v0.3.2
Newf returns a new error with a message formatted as by fmt.Errorf.
If the format has %w verbs, the result wraps those errors, so errors.Is and errors.As still find them. Like Wrap, it also keeps the code, kind, field errors, details and trace of the first *Error among them. Use Newf rather than fmt.Errorf to add context to a message.
Newf takes no options. To set some, wrap the result: Wrap(Newf(...), opts...) adds no extra frame to the trace, because both calls are on the same line. In a helper, use Wrap(fmt.Errorf(...), WithCallerSkip(1)) instead.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func main() {
err := errx.Newf("load user %d: %w", 42, ErrUserNotFound)
fmt.Println(err)
fmt.Println(errors.Is(err, ErrUserNotFound), errx.CodeOf(err))
}
Output: load user 42: user not found true USER_NOT_FOUND
func Wrap ¶
Wrap returns an error that wraps err and adds the caller's location to the trace. It returns nil if err is nil, including a nil *Error.
The result has the same message as err. It keeps the code, kind, field errors and details of the first *Error in err's chain, and options change them. Otherwise the result has no code and the kind that KindOf reports for err.
Example ¶
package main
import (
"database/sql"
"errors"
"fmt"
"github.com/code19m/errx"
)
func main() {
err := errx.Wrap(sql.ErrNoRows,
errx.WithKind(errx.KindNotFound),
errx.WithDetails(errx.Details{"table": "users"}),
)
fmt.Println(err)
fmt.Println(errors.Is(err, sql.ErrNoRows), errx.KindOf(err))
}
Output: sql: no rows in result set true not_found
Types ¶
type Details ¶ added in v1.0.0
Details holds debugging context, such as {"user_id": 42}. Details are meant for logs: do not send them to clients, for example in an HTTP body or in gRPC status details.
New and Wrap copy the map, but not the values in it, so do not change a value after you create the error.
type Error ¶ added in v1.0.0
type Error struct {
// contains filtered or unexported fields
}
Error is the error type returned by New, Newf and Wrap.
An Error never changes after it is created: options are applied once, and the accessor methods return copies. (Details returns a copy of the map, but its values are shared.) So it is safe for concurrent use and can be stored in a package-level variable as a sentinel error. Always use it as a pointer; do not copy or assign Error values.
Use errors.As to get an *Error from an error chain.
func (*Error) Details ¶ added in v1.0.0
Details returns a copy of the debugging details, or nil if there are none.
func (*Error) FieldErrors ¶ added in v1.0.0
func (e *Error) FieldErrors() FieldErrors
FieldErrors returns a copy of the field errors, or nil if there are none.
func (*Error) Format ¶ added in v1.0.0
Format implements fmt.Formatter. The %+v verb prints everything useful for debugging: the message, a line with the kind, code, field errors and details, and the trace in the layout of a Go stack trace:
load profile 42: user not found kind=not_found code=USER_NOT_FOUND details.user_id=42 example.com/app/repo.(*Repo).Get /src/app/repo/repo.go:19 example.com/app/service.(*Service).Profile /src/app/service/service.go:13
The %+v layout is meant for people and may change. All other verbs format only the message, as a string.
func (*Error) LogValue ¶ added in v1.0.0
LogValue implements slog.LogValuer, so slog.Any("err", err) logs the error as a group. With slog.JSONHandler:
"err": {
"message": "load profile 42: user not found",
"kind": "not_found",
"code": "USER_NOT_FOUND",
"details": {"user_id": 42},
"trace": ["repo.(*Repo).Get (repo.go:19)", "service.(*Service).Profile (service.go:13)"]
}
With slog.TextHandler, the trace is one value:
err.trace="repo.(*Repo).Get (repo.go:19) <- service.(*Service).Profile (service.go:13)"
The keys are message, kind, code, field_errors, details and trace; they are stable. The code, field_errors, details and trace are left out when they are empty.
slog calls LogValue only when the logged error itself is an *Error. Use Attr to log an error that may be wrapped by another package, for example with fmt.Errorf.
Example ¶
package main
import (
"log/slog"
"os"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func findUser(id int) error {
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}
// stableOutput drops the time and the trace from log records, so the
// examples have the same output on every machine.
func stableOutput(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey || a.Key == "trace" {
return slog.Attr{}
}
return a
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: stableOutput}))
logger.Error("request failed", "err", findUser(42))
}
Output: {"level":"ERROR","msg":"request failed","err":{"message":"user not found","kind":"not_found","code":"USER_NOT_FOUND","details":{"user_id":42}}}
func (*Error) Trace ¶ added in v1.0.0
Trace returns the locations where the error was created and wrapped. The first frame is where the error was created, and each next frame is a later call to Wrap or Newf. It returns nil if there are no frames.
The trace records only these calls, not full stack traces, so it stays small and cheap to capture. It leaves out the line where a package-level error is declared with New: a sentinel error's trace starts where it is wrapped, because that is where the failure happens. A frame that repeats the one before it, as in Wrap(Newf(...)), is shown once.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func findUser(id int) error {
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}
func main() {
err := findUser(7)
// The declaration of ErrUserNotFound is not part of the trace.
var e *errx.Error
errors.As(err, &e)
for _, f := range e.Trace() {
fmt.Println(f.Function)
}
}
Output: github.com/code19m/errx_test.findUser
type FieldErrors ¶ added in v1.0.0
FieldErrors holds validation messages for input fields, such as {"password": {"too short", "needs a digit"}}. A field can have more than one message, so a client can show every problem at once.
Keys are the field names that clients know, such as JSON names, or paths such as "address.city" or "items[0].name". Clients show the messages to people, so write them for your users.
type Frame ¶ added in v1.0.0
type Frame struct {
// Function is the package path-qualified function name,
// such as "example.com/app/repo.(*Repo).Get".
Function string
// File is the path of the source file as recorded by the compiler:
// a full path, or, in builds with -trimpath, a path that starts with
// the module path (and version, for dependencies).
File string
// Line is the line number in File.
Line int
}
Frame is one location in an error's trace.
type Kind ¶ added in v1.0.0
type Kind uint8
Kind is the category of an error. It tells callers how to react, for example which HTTP status to return. The zero value is KindInternal.
The names from Kind.String and the numeric values are stable, so they are safe to store, to send to other services and to use as metric labels. New kinds may be added in minor releases, so a switch on Kind should have a default case. Values other than the constants below are reserved.
const ( // KindInternal means an unexpected failure inside the program. KindInternal Kind = iota // KindValidation means the input is invalid. Field errors often say why. KindValidation // KindNotFound means the requested resource does not exist. KindNotFound // KindConflict means the request conflicts with the current state, // for example because the resource already exists. KindConflict // KindAuthentication means the caller's identity is missing or invalid. KindAuthentication // KindForbidden means the caller is known but not allowed to do this. KindForbidden // KindThrottling means the caller sent too many requests. KindThrottling // unavailable for now. The caller may retry later. KindUnavailable // KindTimeout means the operation did not finish in time. // [Wrap], [Newf] and [KindOf] use it for [context.DeadlineExceeded]. KindTimeout // KindCanceled means the caller canceled the operation. // [Wrap], [Newf] and [KindOf] use it for [context.Canceled]. KindCanceled )
func KindOf ¶ added in v1.0.0
KindOf returns the kind of the first *Error in err's chain. For an errors.Join error, that is the first *Error in the joined list.
If the chain has no *Error, KindOf returns KindCanceled for context.Canceled, KindTimeout for context.DeadlineExceeded, and KindInternal for all other errors: an error that errx knows nothing about is treated as unexpected. KindOf(nil) also returns KindInternal, so check err != nil first.
Example ¶
This example maps kinds to HTTP status codes. Transport code like this belongs in your application, not in errx.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/code19m/errx"
)
var ErrUserNotFound = errx.New("user not found",
errx.WithCode("USER_NOT_FOUND"),
errx.WithKind(errx.KindNotFound),
)
func findUser(id int) error {
return errx.Wrap(ErrUserNotFound, errx.WithDetails(errx.Details{"user_id": id}))
}
func main() {
httpStatus := func(kind errx.Kind) int {
switch kind {
case errx.KindValidation:
return http.StatusBadRequest
case errx.KindNotFound:
return http.StatusNotFound
case errx.KindConflict:
return http.StatusConflict
case errx.KindAuthentication:
return http.StatusUnauthorized
case errx.KindForbidden:
return http.StatusForbidden
case errx.KindThrottling:
return http.StatusTooManyRequests
case errx.KindUnavailable:
return http.StatusServiceUnavailable
case errx.KindTimeout:
return http.StatusGatewayTimeout
case errx.KindCanceled:
return 499 // "client closed request", as nginx calls it
default:
return http.StatusInternalServerError
}
}
fmt.Println(httpStatus(errx.KindOf(findUser(7))))
fmt.Println(httpStatus(errx.KindOf(errx.Wrap(context.Canceled))))
fmt.Println(httpStatus(errx.KindOf(errors.New("boom"))))
}
Output: 404 499 500
func (Kind) MarshalText ¶ added in v1.0.0
MarshalText implements encoding.TextMarshaler. It returns the name from Kind.String, so a Kind is written as a string in JSON.
func (*Kind) UnmarshalText ¶ added in v1.0.0
UnmarshalText implements encoding.TextUnmarshaler. It accepts the names returned by Kind.String, such as "not_found". An unknown name decodes as KindInternal without an error, so a service on a newer version of errx can send kinds that this version does not know.
type Option ¶ added in v1.0.0
type Option interface {
// contains filtered or unexported methods
}
An Option sets a property of an error created by New or Wrap. A nil Option is ignored.
func WithCallerSkip ¶ added in v1.0.0
WithCallerSkip makes New or Wrap record a location n frames further up the call stack. Use it in your own helpers that create or wrap errors, so the trace shows the code that called the helper, not the helper:
// dbError wraps an error from the database driver.
func dbError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return errx.Wrap(err, errx.WithCallerSkip(1), errx.WithKind(errx.KindNotFound))
}
return errx.Wrap(err, errx.WithCallerSkip(1))
}
Skips add up when the option is given more than once, so a helper that calls another helper adds its own WithCallerSkip(1). A negative total counts as zero.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/code19m/errx"
)
// notFound is a helper that many functions could use to create errors.
func notFound(what string) error {
return errx.New(what+" not found", errx.WithKind(errx.KindNotFound), errx.WithCallerSkip(1))
}
func main() {
err := notFound("order")
// The trace starts at the caller of notFound, not inside it.
var e *errx.Error
errors.As(err, &e)
fmt.Println(e.Trace()[0].Function)
}
Output: github.com/code19m/errx_test.ExampleWithCallerSkip
func WithCode ¶
WithCode sets the error code: a stable, machine-readable name such as "USER_NOT_FOUND". Programs can check it with CodeOf. New sets no code by default, and Wrap keeps the code of the wrapped error.
func WithDetails ¶
WithDetails adds debugging details. For a key that is already set, the new value wins.
func WithFieldErrors ¶ added in v1.0.0
func WithFieldErrors(fields FieldErrors) Option
WithFieldErrors adds validation messages for input fields. If a field already has messages, the new ones are added after them. Fields with no messages are ignored.
Example ¶
package main
import (
"encoding/json"
"errors"
"fmt"
"github.com/code19m/errx"
)
func main() {
err := errx.New("invalid input",
errx.WithKind(errx.KindValidation),
errx.WithFieldErrors(errx.FieldErrors{
"email": {"invalid format"},
"password": {"too short", "needs a digit"},
}),
)
// An HTTP handler can send these parts to the client.
var e *errx.Error
errors.As(err, &e)
body, _ := json.Marshal(map[string]any{
"message": e.Error(),
"kind": e.Kind(),
"field_errors": e.FieldErrors(),
})
fmt.Println(string(body))
}
Output: {"field_errors":{"email":["invalid format"],"password":["too short","needs a digit"]},"kind":"validation","message":"invalid input"}