Go error wrapping and handling patterns for microservices. Covers AppError struct (Code, Message, HTTPStatus, cause), constructor functions (NewNotFound, NewBadRequest, NewInternal, NewUnauthorized, NewConflict), error wrapping with fmt.Errorf %w, sentinel errors with errors.Is/errors.As, error-to-HTTP status mapping middleware, error catalog pattern, and error logging rules. Keywords: error, AppError, wrapping, sentinel, HTTP status, catalog, errors.Is, errors.As, middleware, logging.
Standardizes error creation, wrapping, and propagation across all microservices using a common AppError type and HTTP mapping middleware.
// pkg/apperror/error.go
package apperror
import "fmt"
type AppError struct {
Code string // Machine-readable: "NOT_FOUND", "VALIDATION_FAILED"
Message string // User-friendly, safe for API responses
HTTPStatus int
cause error // Wrapped underlying error (unexported)
}
func (e *AppError) Error() string {
if e.cause != nil { return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.cause) }
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.cause }
func NewNotFound(resource, id string) *AppError {
return &AppError{Code: "NOT_FOUND", Message: fmt.Sprintf("%s with id %s not found", resource, id), HTTPStatus: 404}
}
func NewBadRequest(message string) *AppError {
return &AppError{Code: "BAD_REQUEST", Message: message, HTTPStatus: 400}
}
func NewInternal(cause error) *AppError {
return &AppError{Code: "INTERNAL_ERROR", Message: "an internal error occurred", HTTPStatus: 500, cause: cause}
}
func NewUnauthorized(message string) *AppError {
return &AppError{Code: "UNAUTHORIZED", Message: message, HTTPStatus: 401}
}
func NewConflict(message string) *AppError {
return &AppError{Code: "CONFLICT", Message: message, HTTPStatus: 409}
}
// Wrap attaches a cause to an AppError, returning a new instance (immutable).
func Wrap(appErr *AppError, cause error) *AppError {
return &AppError{Code: appErr.Code, Message: appErr.Message, HTTPStatus: appErr.HTTPStatus, cause: cause}
}
Add context at every layer boundary using %w:
// Repository layer
func (r *Repo) FindByID(ctx context.Context, id uuid.UUID) (*model.Order, error) {
if err := row.Scan(&order); err != nil {
return nil, fmt.Errorf("querying order %s: %w", id, err)
}
return &order, nil
}
// Application layer
order, err := h.repo.FindByID(ctx, id)
if err != nil { return nil, fmt.Errorf("getting order for display: %w", err) }
// internal/domain/model/errors.go
var (
ErrInsufficientStock = errors.New("insufficient stock for requested quantity")
ErrAlreadyCompleted = errors.New("entity is already in completed state")
ErrInvalidTransition = errors.New("invalid state transition")
)
Checking:
if errors.Is(err, model.ErrInsufficientStock) {
return apperror.NewBadRequest("not enough stock available")
}
var appErr *apperror.AppError
if errors.As(err, &appErr) {
log.Printf("app error: code=%s status=%d", appErr.Code, appErr.HTTPStatus)
}
type ErrorResponse struct {
Error struct { Code string `json:"code"`; Message string `json:"message"` } `json:"error"`
}
func ErrorHandler(logger *zap.Logger, handler func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := handler(w, r); err == nil { return } else {
var appErr *apperror.AppError
if !errors.As(err, &appErr) { appErr = apperror.NewInternal(err) }
if appErr.HTTPStatus >= 500 {
logger.Error("internal error", zap.Error(err), zap.String("code", appErr.Code))
} else {
logger.Warn("client error", zap.String("code", appErr.Code))
}
w.Header().Set("Content-Type", "application/x-msgpack")
w.WriteHeader(appErr.HTTPStatus)
resp := ErrorResponse{}
resp.Error.Code = appErr.Code; resp.Error.Message = appErr.Message
_ = msgpack.NewEncoder(w).Encode(resp)
}
}
}
Centralize all domain-specific errors per service:
var (
ErrOrderNotFound = apperror.NewNotFound("order", "")
ErrDuplicateOrder = apperror.NewConflict("order with this reference already exists")
ErrInvalidQuantity = apperror.NewBadRequest("quantity must be greater than zero")
)
// Usage: return apperror.Wrap(errors.ErrOrderNotFound, fmt.Errorf("id: %s", id))
| Scenario | Level | Log Details |
|---|---|---|
| 5xx internal | ERROR | Full stack, cause, request context |
| 4xx client | WARN | Error code and message only |
| Transient (retry pending) | WARN | Error, attempt number |
| Expected (not found) | DEBUG | Resource type and ID |
Never log: passwords, tokens, PII, full request bodies with sensitive data.
For JetStream-based request-reply (inter-service queries), error responses
use the same AppError envelope serialized as MsgPack (NOT JSON). The responder publishes the error response
to the reply subject extracted from the incoming message's msg.Reply (core NATS auto-set)
header. nc.Publish() is FORBIDDEN — use nc.Request() for RPC or js.PublishMsg() for events -- use js.PublishMsg to the
reply subject instead.
// JetStream reply with AppError envelope -- publish to reply subject (MsgPack-encoded)
func replyWithError(ctx context.Context, js nats.JetStreamContext, msg *nats.Msg, appErr *AppError) error {
replySubject := msg.Header.Get("msg.Reply (core NATS auto-set)")
if replySubject == "" {
return fmt.Errorf("no msg.Reply (core NATS auto-set) header in message")
}
resp := ErrorResponse{}
resp.Error.Code = appErr.Code
resp.Error.Message = appErr.Message
data, _ := msgpack.Marshal(resp) // MsgPack for all NATS inter-service payloads
replyMsg := &nats.Msg{
Subject: replySubject,
Data: data,
Header: nats.Header{},
}
replyMsg.Header.Set("X-Error-Code", appErr.Code)
replyMsg.Header.Set("X-Error-Status", fmt.Sprintf("%d", appErr.HTTPStatus))
// Publish reply via JetStream (NOT msg.Respond which uses core NATS)
_, err := js.PublishMsg(replyMsg)
return err
}
// Client-side: detect error in JetStream reply (received via core NATS request-reply)
func checkReplyError(reply *nats.Msg) error {
if errCode := reply.Header.Get("X-Error-Code"); errCode != "" {
status, _ := strconv.Atoi(reply.Header.Get("X-Error-Status"))
return &AppError{
Code: errCode,
Message: string(reply.Data),
HTTPStatus: status,
}
}
return nil
}
// Using core NATS request-reply (JetStream-based request-reply, NOT core NATS nc.Request)
reply, err := replyRouter.Request(ctx, subject, data, headers, 5*time.Second)
if errors.Is(err, context.DeadlineExceeded) {
// Record failure in circuit breaker
cb.RecordFailure(subject)
return nil, fmt.Errorf("service %s unavailable (timeout): %w", subject, err)
}
AppError before the HTTP boundary (API Gateway) or NATS reply boundary (domain services).if err != nil must return, log, or handle explicitly.errors.Is/errors.As, never compare .Error().NewInternal uses a generic message deliberately.context.DeadlineExceeded with circuit breaker patterns to prevent cascading failures.msg.Respond uses core NATS which is FORBIDDEN. Error replies must be published via js.PublishMsg to the msg.Reply (core NATS auto-set) header extracted from the incoming message.