A transport-agnostic error taxonomy for Go services.
Your domain and application layers return *errx.Error with a Kind. Your
transport layer decides what that means on the wire. errx itself knows nothing
about HTTP handlers, frameworks or response envelopes, and has no dependencies
outside the standard library.
go get github.com/oshturhq/errxReturn errors from anywhere in your call stack:
func (s *UserService) Get(ctx context.Context, id string) (*User, error) {
user, err := s.users.ByID(ctx, id)
if err != nil {
return nil, errx.NotFound("user not found")
}
return user, nil
}
func (r *UserRepository) Create(ctx context.Context, user *User) error {
if _, err := r.pool.Exec(ctx, query, user.ID); err != nil {
if isUniqueViolation(err) {
return errx.Conflict("a user with this email already exists")
}
return errx.Internal("failed to create user", err)
}
return nil
}Translate once, at the edge:
func errorHandler(w http.ResponseWriter, err error) {
appErr := errx.From(err)
body := map[string]string{"code": appErr.Kind.String()}
if appErr.Kind.IsClient() {
body["cause"] = appErr.Message
}
writeJSON(w, appErr.HTTPStatus(), body)
}Server-side messages are kept out of responses on purpose: they usually carry the underlying failure, which belongs in your logs rather than in a payload.
| Kind | HTTP | Kind | HTTP | |
|---|---|---|---|---|
bad_request |
400 | payload_too_large |
413 | |
unauthorized |
401 | unsupported_media_type |
415 | |
forbidden |
403 | validation |
422 | |
not_found |
404 | too_many_requests |
429 | |
method_not_allowed |
405 | canceled |
499 | |
conflict |
409 | internal |
500 | |
already_exists |
409 | not_implemented |
501 | |
gone |
410 | bad_gateway |
502 | |
precondition_failed |
412 | unavailable |
503 | |
timeout |
504 |
canceled maps to 499, the non-standard code nginx popularised for a request
the client abandoned before the server answered.
Client-side kinds take a message. Server-side kinds take the error they wrap, because they almost always have an underlying cause worth keeping:
errx.NotFound("user not found")
errx.AlreadyExists("this email is already registered")
errx.Validation("name is required")
errx.Conflict("email already taken")
errx.Internal("failed to create user", pgErr)
errx.Unavailable("payment provider is down", dialErr)
errx.Timeout("upstream took too long", ctx.Err())Every message is yours. No constructor composes or rewrites it, so nothing stands between you and the wording your API, or your translations, need.
New(kind, message) and Wrap(err, kind, message) cover any combination the
helpers do not.
errx.Is(err, errx.KindNotFound) // true through fmt.Errorf("%w") chains
errx.KindOf(err) // KindInternal for errors that are not *errx.Error
errx.StatusOf(err) // 404, or 200 for a nil error
errx.From(err) // *errx.Error, wrapping unknown errors as internal
errx.IsRetryable(err) // timeout, unavailable, bad_gateway, too_many_requests*errx.Error implements Unwrap, so the standard library keeps working:
dbErr := errx.Internal("query failed", sql.ErrNoRows)
errors.Is(dbErr, sql.ErrNoRows) // true
errors.AsType[*errx.Error](dbErr) // okkind.IsClient() // 4xx: the caller can fix it
kind.IsServer() // 5xx: you can fix it
kind.IsRetryable() // worth another attempt with backoffIsRetryable is meant to drive http client retry policies and worker requeue
decisions without every caller re-deriving the same list.
errx deliberately carries no wire format. The Kind values are stable, lower
snake case strings, so they can be used directly as machine readable error codes
in whatever envelope your API already speaks.
There is no field level validation detail. One kind out of nineteen would use
it, a map[string]string cannot express repeated or nested violations, and the
shape of that payload belongs to your response contract rather than to a
taxonomy package. Attach it in your transport layer instead.