Designing a safe error handling package in Go: safe by default

Error handling in Go is simple. Sometimes too simple. A fmt.Errorf("pq: no rows in result set") bubbling up to an HTTP handler, and suddenly SQL internals are exposed in a 500 response. No exotic vulnerability required — just an err.Error() in the wrong place.

This problem is systemic. It doesn't stem from a lack of discipline, but from the absence of a boundary in the type system. Nothing in the error interface distinguishes what is safe to expose from what is not.

The concrete problem

Here is the classic leak pattern. A handler that serves err.Error() directly in its JSON response, and a repository layer that wraps errors with technical context:

// HTTP handler
func (h *Handler) GetOrder(c echo.Context) error {
    order, err := h.service.FindByID(ctx, id)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": err.Error(), // ← LEAK
        })
    }
    return c.JSON(http.StatusOK, order)
}

// repository layer
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
    var o Order
    err := r.db.GetContext(ctx, &o, "SELECT * FROM orders WHERE id = $1", id)
    if err != nil {
        return nil, fmt.Errorf("orders.FindByID: %w", err) // standard wrapping
    }
    return &o, nil
}

Response received by the client:

{"error": "orders.FindByID: pq: no rows in result set"}

The table name, PostgreSQL driver, and nature of the SQL error are publicly exposed. Not catastrophic in isolation — but exactly the kind of information an attacker uses to refine an injection or target an attack surface.

What existing solutions offer (and their limits)

Solution Approach Limitation
cockroachdb/errors Exhaustive package, stack traces, rich wrapping ~15k lines, 8 sub-packages. Disproportionate for a business microservice.
upspin pattern (Rob Pike) Error type with Kind, Op, Err Pre-Go 1.13, no slog, and errors.Is() traverses the full chain.
hashicorp pattern UserError interface with GetMessage() Relies on developer discipline, not the type system.

The real question: can we design a type where err.Error() always returns something safe, without any convention to remember?

The safeerr design

Five principles structure the package:

  1. Safe by default: Error() returns only the user-facing message.
  2. No Unwrap(): the introspection chain is intentionally broken. errors.Is() never traverses the internal cause. A deliberate security choice.
  3. slog.LogValuer: technical details are exposed only through structured logs.
  4. Immutable builder pattern: WithMsg(), WrapCause() always return a new instance — sentinels are never mutated.
  5. Centralized HTTP mapping: a single point maps ErrorKind values to HTTP status codes.

The sentinels

var (
    ErrNotFound     = New("NOT_FOUND",     KindNotFound,     http.StatusNotFound,            "not found")
    ErrUnauthorized = New("UNAUTHORIZED",  KindUnauthorized, http.StatusUnauthorized,        "unauthorized")
    ErrInvalidInput = New("INVALID_INPUT", KindInvalidInput, http.StatusUnprocessableEntity, "invalid input")
    ErrInvalidState = New("INVALID_STATE", KindInvalidState, http.StatusConflict,            "invalid state")
    ErrInternal     = New("INTERNAL",      KindInternal,     http.StatusInternalServerError, "internal error")
)

The immutable builder pattern

func (e *AppErr) WithMsg(msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}

func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}

Each call creates a new instance. Sentinels declared as var are never modified — no risk of shared mutation across goroutines.

slog.LogValuer

func (e *AppErr) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("code", e.code),
        slog.String("msg", e.msg),
    }
    if e.cause != nil {
        attrs = append(attrs, slog.String("cause", e.cause.Error()))
    }
    return slog.GroupValue(attrs...)
}

Resulting log:

level=ERROR msg="order processing failed" err.code=NOT_FOUND err.msg="order not found" err.cause="pq: no rows in result set"

Technical details — driver, query, table — remain in the logs. Never in the HTTP response.

Centralized HTTP mapping

func Error(c echo.Context, err error) error {
    var appErr *safeerr.AppErr
    if errors.As(err, &appErr) {
        return c.JSON(appErr.Status(), map[string]string{
            "error": appErr.Error(),
        })
    }
    slog.Error("unhandled error reaching handler", "err", err)
    return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
}

A single place in the codebase knows how to translate an error into an HTTP response. If an untyped error reaches this point, it is logged and returns a generic message.

Before / after

Before — technical errors bubble up raw to the handler:

// service — raw technical error propagated
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("FindOrder: %w", err)
    }
    if order.ContractRef == "" {
        return errors.New("order has no contract reference")
    }
    return nil
}

After — explicit conversion at the service boundary:

// service — explicit conversion at the boundary
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return safeerr.ErrNotFound.WrapCause(err, "order not found")
    }
    if order.ContractRef == "" {
        return safeerr.ErrInvalidState.WithMsg("order has no contract reference")
    }
    return nil
}

// handler
func (h *Handler) ProcessOrder(c echo.Context) error {
    if err := h.service.ProcessOrder(ctx, cmd); err != nil {
        return render.Error(c, err)
    }
    return c.JSON(http.StatusOK, nil)
}

HTTP response: {"error": "order not found"} — the SQL cause stays in the logs, never in the response.

The full package (~130 lines)

package safeerr

import "log/slog"

type ErrorKind int

const (
    KindNotFound ErrorKind = iota
    KindUnauthorized
    KindForbidden
    KindInvalidInput
    KindInvalidState
    KindConflict
    KindInternal
)

type AppErr struct {
    code   string
    kind   ErrorKind
    status int
    msg    string
    cause  error
}

func New(code string, kind ErrorKind, status int, msg string) *AppErr {
    return &AppErr{code: code, kind: kind, status: status, msg: msg}
}

func (e *AppErr) Error() string   { return e.msg }
func (e *AppErr) Code() string    { return e.code }
func (e *AppErr) Kind() ErrorKind { return e.kind }
func (e *AppErr) Status() int     { return e.status }
func (e *AppErr) Cause() error    { return e.cause }

func (e *AppErr) WithMsg(msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}

func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}

func (e *AppErr) WithCause(cause error) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: e.msg, cause: cause}
}

func (e *AppErr) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("code", e.code),
        slog.String("msg", e.msg),
    }
    if e.cause != nil {
        attrs = append(attrs, slog.String("cause", e.cause.Error()))
    }
    return slog.GroupValue(attrs...)
}

Honest trade-offs

  • No stack traces. AppErr does not capture the goroutine stack. Structured logs with business context compensate in most cases, but for deep post-mortem debugging this is a real limitation.
  • WrapCause can leak if misused. WrapCause(err, err.Error()) breaks the safety guarantee — the user-facing message becomes the technical one. Discipline is still required on this specific point.
  • The conversion cost. Every technical error must be converted to an AppErr at the service boundary. This is intentional friction — on a project with a hundred error sites, that's code to write and maintain.
  • errors.Is() no longer works on the cause. If an upper layer wants to test errors.Is(err, sql.ErrNoRows), it cannot. Acceptable if the sentinel taxonomy is expressive enough, problematic otherwise.

Conclusion

The real value of this package is not in the 130 lines. It's in the boundary it makes explicit between what is safe to expose and what is not. That boundary already existed in any well-designed application — but it relied on discipline, on documented conventions, on careful reviews.

Moving this constraint into the type system has a cost: explicit conversion at every boundary. In return: an accidental err.Error() in an HTTP response never leaks anything, regardless of who wrote the code.

For an internal library shared across multiple microservices in the same stack, it's an investment that pays off quickly.

Comments (0)