Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

errx

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/errx

Usage

Return 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.

Kinds

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.

Constructors

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.

Inspecting

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) // ok

Classification

kind.IsClient()     // 4xx: the caller can fix it
kind.IsServer()     // 5xx: you can fix it
kind.IsRetryable()  // worth another attempt with backoff

IsRetryable is meant to drive http client retry policies and worker requeue decisions without every caller re-deriving the same list.

Notes

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages