Give each Go error a type code, an HTTP status, and a gRPC code, and keep them as the error is wrapped, returned, and sent between services.
var ErrUserNotFound = errors.NewKind("USER_NOT_FOUND", errors.ErrNotFound, "user not found")
err := ErrUserNotFound.Wrap(sql.ErrNoRows, "find user 42")
errors.TypeCode(err) // "USER_NOT_FOUND"
errors.HTTPCode(err) // 404
errors.GRPCCode(err) // codes.NotFound
errors.Is(err, ErrUserNotFound) // true
errors.Is(err, errors.ErrNotFound) // true
errors.Is(err, sql.ErrNoRows) // trueWithout this, services usually map errors to status codes in each handler. Those mappings drift apart and lose detail at every gRPC hop. With this package, you classify an error once, where it happens:
- One classification, every transport. HTTP handlers call
HTTPCode(err), gRPC servers returnSendGRPCError(err), and logs recordTypeCode(err). - Your own sentinel errors.
NewKindcreates errors likeUSER_NOT_FOUNDthat you check witherrors.Is. They also match a broader category, such asErrNotFound, so generic code can handle them. - Classifications cross gRPC. A client gets an error that still matches
the server's
Kind, with the same type code, HTTP status, and gRPC code. A gateway can forward it to HTTP without its own mapping table. - Safe messages for clients.
PublicMessageandPublicgive clients a message without internal details such as SQL errors or file paths. - Works with the standard library. Errors work with
errors.Is,errors.As, andfmt.Errorf("%w"). The package re-exportsIs,As,AsType,Unwrap,Join, andNew, so it can replace the standard import.
go get github.com/stackus/errorsRequires Go 1.26.8 or later.
- Quick start
- Defining your errors
- Classifying errors and adding context
- Checking errors
- Reading codes
- Public messages
- HTTP responses
- gRPC
- Using your own error types
- Built-in categories
An error is classified where it enters the application. Code above it adds context, and the caller reads the codes and checks what the error is:
var ErrUserNotFound = errors.NewKind(
"USER_NOT_FOUND",
errors.ErrNotFound,
"user not found",
errors.WithPublicMessage("The user could not be found"),
)
// Repository: classify the driver error.
func (r *Repo) FindUser(ctx context.Context, id string) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
if err := row.Scan(...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound.Wrap(err, "find user "+id)
}
return nil, errors.ErrInternal.Wrap(err, "find user "+id)
}
...
}
// Service: add context. The classification is preserved.
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.FindUser(ctx, id)
if err != nil {
return nil, errors.Wrap(err, "get profile")
}
...
}err := svc.GetProfile(ctx, "42")
fmt.Println(err) // get profile: find user 42: sql: no rows in result set
fmt.Println(errors.TypeCode(err)) // USER_NOT_FOUND
fmt.Println(errors.HTTPCode(err)) // 404
fmt.Println(errors.GRPCCode(err)) // NotFound
fmt.Println(errors.Is(err, ErrUserNotFound)) // true
fmt.Println(errors.Is(err, errors.ErrNotFound)) // true
fmt.Println(errors.PublicMessage(err)) // The user could not be foundThe package includes a category for every gRPC code and for common HTTP
statuses. Examples are ErrNotFound, ErrInvalidArgument, ErrConflict, and
ErrUnauthorized. See the full table. A category is
already a sentinel error:
if user == nil {
return errors.ErrNotFound
}Categories are enough for most generic failures, such as "not found", "invalid input", or "internal".
Create a Kind for an error that callers need to tell apart from others, or
that should have its own code in logs, metrics, and API responses. Declare
kinds as package-level variables, just like var ErrX = errors.New(...):
var (
ErrOrderNotFound = errors.NewKind("ORDER_NOT_FOUND", errors.ErrNotFound, "order not found")
ErrOrderShipped = errors.NewKind("ORDER_SHIPPED", errors.ErrFailedPrecondition, "order already shipped")
)A kind has three parts:
- Type code. An uppercase string such as
ORDER_NOT_FOUND. It can containA-Z,0-9,_, and.. It can't be the same as a built-in category's code. - Category. A built-in category. The kind inherits its HTTP and gRPC codes,
and errors made from the kind match the category with
errors.Is. - Message. The error text of the kind itself.
Options adjust a kind when it's created:
var ErrPaymentRequired = errors.NewKind(
"PAYMENT_REQUIRED",
errors.ErrFailedPrecondition,
"account has no active subscription",
errors.WithHTTPCode(http.StatusPaymentRequired), // 402 instead of 400
errors.WithPublicMessage("A subscription is required"), // shown to clients
)
var ErrQuotaExceeded = errors.NewKind(
"QUOTA_EXCEEDED",
errors.ErrForbidden,
"storage quota exceeded",
errors.WithGRPCCode(codes.ResourceExhausted), // instead of PermissionDenied
)NewKind panics on invalid input, such as a bad type code or an HTTP code
outside 400–599. Kinds are declared at package level, so these mistakes
surface when the program starts.
Categories and kinds have the same methods for creating errors:
| Method | Result text | Keeps a cause | Use it to |
|---|---|---|---|
Msg(msg) / Msgf(...) |
msg |
no | create a new error with a detailed message |
WithCause(err) |
err.Error() |
yes | classify an error without changing its text |
Wrap(err, msg) / Wrapf |
msg: err |
yes | classify an error and add context |
errors.ErrInvalidArgument.Msg("page size must be positive")
ErrOrderShipped.Msgf("order %d shipped at %s", id, shippedAt)
errors.ErrUnavailable.WithCause(err)
ErrEmailTaken.Wrap(pgErr, "register user")Wrap, Wrapf, and WithCause return nil when the error is nil.
errors.WithKind(err, kind) does the same as kind.WithCause(err). Use it to
classify a call's result directly:
return errors.WithKind(s.store.Put(ctx, obj), ErrStorageUnavailable)After an error is classified, add context as it moves up through your code.
The package-level errors.Wrap / errors.Wrapf and fmt.Errorf with %w
all keep the classification and the cause:
err := errors.ErrNotFound.Wrap(sql.ErrNoRows, "find user")
err = fmt.Errorf("repository: %w", err)
err = errors.Wrap(err, "login")
fmt.Println(err) // login: repository: find user: sql: no rows in result set
fmt.Println(errors.TypeCode(err)) // NOT_FOUNDWhen errors.Wrap is given a category directly, it uses the message as the
whole error text:
err := errors.Wrap(errors.ErrNotFound, "user 42 not found")
fmt.Println(err) // user 42 not foundThe outermost classification wins. To change what an error means as it crosses a boundary, wrap it with another category or kind:
// A missing user during login is an authentication failure, not a 404.
err := errors.ErrUnauthenticated.Wrap(err, "login")The original error is still in the chain, so errors.Is(err, ErrUserNotFound)
is still true.
Use errors.Is with a kind to match exactly, or with a category to match
anything in that category:
switch {
case errors.Is(err, ErrOrderShipped):
// this specific failure
case errors.Is(err, errors.ErrNotFound):
// any "not found", including ErrOrderNotFound
}Two kinds are always different errors, even when they're in the same category.
Use errors.As or the generic errors.AsType to get an error value or an
interface from the chain:
if coder, ok := errors.AsType[errors.TypeCoder](err); ok {
log.Printf("type=%s", coder.TypeCode())
}errors.TypeCode(err), errors.HTTPCode(err), and errors.GRPCCode(err)
look through the whole error chain:
| Error | TypeCode |
HTTPCode |
GRPCCode |
|---|---|---|---|
| category or kind, anywhere in the chain | its type code | its status | its code |
gRPC status error (status.Error, client call) |
from its code and detail | from its code and detail | its code |
context.Canceled |
CANCELED |
408 | Canceled |
context.DeadlineExceeded |
DEADLINE_EXCEEDED |
504 | DeadlineExceeded |
errors.Join(...) where all children agree |
the shared code | shared | shared |
errors.Join(...) where children differ |
UNKNOWN |
500 | Unknown |
unclassified (errors.New, third-party errors) |
UNKNOWN |
500 | Unknown |
nil |
OK |
200 | OK |
ErrOK returned or wrapped as an error |
UNKNOWN |
500 | Unknown |
If an error has several classifications in its chain, the outermost one is
used. To choose the classification of a joined error, wrap the join:
errors.ErrInvalidArgument.WithCause(errors.Join(errs...)).
Unclassified errors are reported as UNKNOWN so that they stay separate from
errors you have deliberately marked ErrInternal. When UNKNOWN shows up in
your logs, dashboards, or client responses, it points to an error that hasn't
been classified yet.
A non-nil error never reports success: HTTPCode(err) is always 400–599 and
GRPCCode(err) is never OK when err != nil. Only nil gives OK, 200,
and codes.OK.
Error text often includes SQL errors, hostnames, or file paths that clients
shouldn't see. errors.PublicMessage(err) returns a message that is safe to
send. It never returns err.Error(). It uses the first of these that applies:
- A message set on this error with
errors.WrapPublicMessage(err, msg). - The kind's
WithPublicMessagetext. "Internal Server Error"for a 5xx status or an unclassified error.- The HTTP status text, such as
"Not Found"or"Conflict".
err := errors.ErrInternal.Wrap(dbErr, "load user")
errors.PublicMessage(err) // "Internal Server Error"
err = errors.WrapPublicMessage(errors.ErrInvalidArgument.Msg("sku ABC-123 failed checksum"),
"The product code is not valid")
errors.PublicMessage(err) // "The product code is not valid"
err.Error() // "sku ABC-123 failed checksum"errors.Public(err) returns a PublicError{Code, Message} that can be
serialized directly:
{"code":"USER_NOT_FOUND","message":"The user could not be found"}A single helper can turn any error into a response:
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(errors.HTTPCode(err))
_ = json.NewEncoder(w).Encode(errors.Public(err))
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
slog.ErrorContext(r.Context(), "get user", "err", err, "type", errors.TypeCode(err))
writeError(w, err)
return
}
...
}| Error | Response |
|---|---|
ErrUserNotFound.Wrap(sql.ErrNoRows, "find user 42") |
404 {"code":"USER_NOT_FOUND","message":"The user could not be found"} |
errors.ErrInternal.Wrap(dialErr, "load user") |
500 {"code":"INTERNAL","message":"Internal Server Error"} |
errors.ErrUnprocessableEntity.Msg("email: missing @") |
422 {"code":"UNPROCESSABLE_ENTITY","message":"Unprocessable Entity"} |
SendGRPCError converts an error into a gRPC status on the server.
ReceiveGRPCError converts it back on the client. The status contains:
- the gRPC code from
GRPCCode(err); - the message from
PublicMessage(err), so internal text stays on the server; - a status detail with the type code, HTTP status, and category;
- any other details from a status error in the chain, such as
errdetails.BadRequest.
On the client, the received error has the sender's type code, category, HTTP status, gRPC code, and public message. That holds across any number of hops.
The grpcerrs subpackage applies these functions to every call:
import "github.com/stackus/errors/grpcerrs"
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(grpcerrs.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(grpcerrs.StreamServerInterceptor()),
)
conn, err := grpc.NewClient(target,
grpc.WithChainUnaryInterceptor(grpcerrs.UnaryClientInterceptor(registry)),
grpc.WithChainStreamInterceptor(grpcerrs.StreamClientInterceptor(registry)),
)Handlers return this package's errors, and callers check them as if the server were local:
// server
func (s *OrderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.svc.GetOrder(ctx, req.GetId())
if err != nil {
return nil, err // e.g. orders.ErrOrderNotFound.Wrap(sql.ErrNoRows, "get order 7")
}
...
}
// client
_, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 7})
errors.Is(err, orders.ErrOrderNotFound) // true, with the kind registered
errors.Is(err, errors.ErrNotFound) // true
errors.TypeCode(err) // "ORDER_NOT_FOUND"
errors.HTTPCode(err) // 404, so a gateway can pass it on
errors.PublicMessage(err) // "Order not found"
err.Error() // "rpc error: code = NotFound desc = Order not found"The stream client interceptor converts errors from opening the stream and
from Header, SendMsg, RecvMsg, and CloseSend. io.EOF is returned
unchanged.
Every received error matches its built-in category and keeps its type code,
HTTP status, and public message. To also match your own kinds with
errors.Is, the client needs the same Kind values, usually from a package
shared with the server, registered in a Registry:
registry, err := errors.NewRegistry(
orders.ErrOrderNotFound,
orders.ErrOrderShipped,
)A received kind is matched by type code, gRPC code, and category. The local kind's definition supplies the HTTP status. You can pass several registries; they are searched in order.
Status errors made with google.golang.org/grpc/status are classified by
their code, whether they are returned by a handler or received without the
client interceptor, and even when wrapped:
err := fmt.Errorf("get order: %w", status.Error(codes.NotFound, "order 7 not found"))
errors.TypeCode(err) // "NOT_FOUND"
errors.HTTPCode(err) // 404
errors.PublicMessage(err) // "Not Found"A status without this package's detail, such as one from status.Error or
from a service that doesn't use this package, keeps its details. Its message
is not treated as public, because it may contain internal text. Use
WrapPublicMessage to choose the message clients see.
- The gRPC status code decides the result. If the detail disagrees with it, the detail is ignored.
- Wrapping a received error with a category or kind reclassifies it. The upstream status details are then not forwarded.
SendGRPCError(nil)andReceiveGRPCError(nil)returnnil.ReceiveGRPCErrorreturns errors that aren't gRPC statuses unchanged.
An existing error type can be classified by implementing one or more of these interfaces. It doesn't need to be wrapped:
type TypeCoder interface { error; TypeCode() string }
type HTTPCoder interface { error; HTTPCode() int }
type GRPCCoder interface { error; GRPCCode() codes.Code }type ValidationError struct{ Field string }
func (e ValidationError) Error() string { return "invalid field: " + e.Field }
func (e ValidationError) TypeCode() string { return "VALIDATION_FAILED" }
func (e ValidationError) HTTPCode() int { return http.StatusUnprocessableEntity }
err := fmt.Errorf("create account: %w", ValidationError{Field: "email"})
errors.TypeCode(err) // "VALIDATION_FAILED"
errors.HTTPCode(err) // 422
errors.GRPCCode(err) // codes.Unknown (not implemented)Codes a type doesn't provide use the UNKNOWN defaults. An HTTP code outside
400–599 is treated as 500, and a gRPC code of OK as Unknown. Every error in
this package implements all three interfaces.
Categories named for gRPC codes:
| Category | Type code | HTTP | gRPC |
|---|---|---|---|
ErrCanceled |
CANCELED |
408 | Canceled |
ErrUnknown |
UNKNOWN |
500 | Unknown |
ErrInvalidArgument |
INVALID_ARGUMENT |
400 | InvalidArgument |
ErrDeadlineExceeded |
DEADLINE_EXCEEDED |
504 | DeadlineExceeded |
ErrNotFound |
NOT_FOUND |
404 | NotFound |
ErrAlreadyExists |
ALREADY_EXISTS |
409 | AlreadyExists |
ErrPermissionDenied |
PERMISSION_DENIED |
403 | PermissionDenied |
ErrResourceExhausted |
RESOURCE_EXHAUSTED |
429 | ResourceExhausted |
ErrFailedPrecondition |
FAILED_PRECONDITION |
400 | FailedPrecondition |
ErrAborted |
ABORTED |
409 | Aborted |
ErrOutOfRange |
OUT_OF_RANGE |
422 | OutOfRange |
ErrUnimplemented |
UNIMPLEMENTED |
501 | Unimplemented |
ErrInternal |
INTERNAL |
500 | Internal |
ErrUnavailable |
UNAVAILABLE |
503 | Unavailable |
ErrDataLoss |
DATA_LOSS |
500 | DataLoss |
ErrUnauthenticated |
UNAUTHENTICATED |
401 | Unauthenticated |
Categories named for HTTP statuses:
| Category | Type code | HTTP | gRPC |
|---|---|---|---|
ErrBadRequest |
BAD_REQUEST |
400 | InvalidArgument |
ErrUnauthorized |
UNAUTHORIZED |
401 | Unauthenticated |
ErrForbidden |
FORBIDDEN |
403 | PermissionDenied |
ErrMethodNotAllowed |
METHOD_NOT_ALLOWED |
405 | Unimplemented |
ErrRequestTimeout |
REQUEST_TIMEOUT |
408 | DeadlineExceeded |
ErrConflict |
CONFLICT |
409 | Aborted |
ErrGone |
GONE |
410 | NotFound |
ErrUnsupportedMediaType |
UNSUPPORTED_MEDIA_TYPE |
415 | InvalidArgument |
ErrImATeapot |
IM_A_TEAPOT |
418 | Unknown |
ErrUnprocessableEntity |
UNPROCESSABLE_ENTITY |
422 | InvalidArgument |
ErrTooManyRequests |
TOO_MANY_REQUESTS |
429 | ResourceExhausted |
ErrUnavailableForLegalReasons |
UNAVAILABLE_FOR_LEGAL_REASONS |
451 | PermissionDenied |
ErrInternalServerError |
INTERNAL_SERVER_ERROR |
500 | Internal |
ErrNotImplemented |
NOT_IMPLEMENTED |
501 | Unimplemented |
ErrBadGateway |
BAD_GATEWAY |
502 | Unavailable |
ErrServiceUnavailable |
SERVICE_UNAVAILABLE |
503 | Unavailable |
ErrGatewayTimeout |
GATEWAY_TIMEOUT |
504 | DeadlineExceeded |
Each category is a separate error, so ErrBadRequest and ErrInvalidArgument
don't match each other with errors.Is, even though both use HTTP 400 and gRPC
InvalidArgument. A category received over gRPC keeps its identity when the
sender used this package.
ErrOK names the codes of a nil error: OK, 200, and codes.OK. It is
not an error to return. Returned or wrapped as an error, it is classified as
UNKNOWN, so a failure can't be reported as a success. It can't be used as a
kind's category or type code.
More runnable examples are in the package documentation.
Pull requests are welcome. Please include tests for behavior changes.