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

Four principles structure the package:

  1. Safe by default: Error() returns only the user-facing message. Even an accidental http.Error(w, err.Error(), 500) leaks nothing.
  2. Unwrap() exposes the cause: the technical cause is accessible via the errors.Is/As chain for logging and debugging, but never through Error().
  3. slog.LogValuer: technical details (code, cause) are exposed only through structured logs.
  4. Immutable builder pattern: WithMsg(), WithCause() always return a new instance — sentinels are never mutated.

The sentinels

var (
    ErrNotFound     = safeerr.New("NOT_FOUND",     "not found")
    ErrUnauthorized = safeerr.New("UNAUTHORIZED",  "unauthorized")
    ErrInvalidInput = safeerr.New("INVALID_INPUT", "invalid input")
    ErrInvalidState = safeerr.New("INVALID_STATE", "invalid state")
    ErrInternal     = safeerr.New("INTERNAL",      "internal error")
)

New takes two fields: the machine code and the user-facing message. HTTP status mapping is delegated to the handler layer — the separation is explicit.

The immutable builder pattern

// Custom user-facing message
err := ErrNotFound.WithMsg("user 42 not found")

// Formatted user-facing message
err = ErrNotFound.WithMsgf("user %d not found", id)

// Technical cause for logs only
err = ErrNotFound.WithCause(dbErr)

// Formatted technical cause
err = ErrNotFound.WithCausef("query %s: %w", table, dbErr)

// Both — chaining is immutable, each call returns a new instance
err = ErrNotFound.WithMsg("user 42 not found").WithCause(dbErr)

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

errors.Is by code

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    return ok && e.code == t.code
}

// In use
err := ErrNotFound.WithMsg("user 42 not found")
errors.Is(err, ErrNotFound) // true — same code, regardless of message or cause

Matching is by code, not by pointer. Two instances derived from the same sentinel always match, regardless of any customisation applied.

slog.LogValuer

func (e *Error) LogValue() slog.Value {
    attrs := []slog.Attr{slog.String("code", e.code), slog.String("msg", e.msg)}
    if e.cause != nil {
        attrs = append(attrs, slog.Any("cause", e.cause))
    }
    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"

slog.Any instead of slog.String: if the cause itself implements slog.LogValuer, it structures recursively in the logs. Technical details never reach Error().

Wrap for service boundaries

// Add context to the cause chain without changing the sentinel
return safeerr.Wrap(repoErr, "UserService.Get")

// Formatted equivalent
return safeerr.Wrapf(repoErr, "UserService.Get user=%s", userID)

Wrap handles two cases: if err is already a *Error, it preserves the code and enriches the cause. If it's a stdlib error, it delegates to fmt.Errorf. One call handles both.

Centralized HTTP mapping

func Error(c echo.Context, err error) error {
    var appErr *safeerr.Error
    if errors.As(err, &appErr) {
        return c.JSON(httpStatus(appErr.Code()), 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. httpStatus() maps machine codes to HTTP status codes — separate from the error definitions themselves.

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 ErrNotFound.WithMsg("order not found").WithCause(err)
    }
    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 (~70 lines)

package safeerr

import (
    "errors"
    "fmt"
    "log/slog"
)

// Error() returns only the user-facing message — safe by default.
type Error struct {
    code  string
    msg   string
    cause error
}

func New(code, msg string) *Error {
    return &Error{code: code, msg: msg}
}

func (e *Error) Error() string { return e.msg }
func (e *Error) Code() string  { return e.code }
func (e *Error) Unwrap() error { return e.cause }

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    return ok && e.code == t.code
}

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

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

func (e *Error) WithMsgf(format string, args ...any) *Error {
    return e.WithMsg(fmt.Sprintf(format, args...))
}

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

func (e *Error) WithCausef(format string, args ...any) *Error {
    return e.WithCause(fmt.Errorf(format, args...))
}

func Wrap(err error, msg string) error {
    if err == nil {
        return nil
    }
    if e, ok := err.(*Error); ok {
        if inner := e.Unwrap(); inner != nil {
            return e.WithCause(fmt.Errorf("%s: %w", msg, inner))
        }
        return e.WithCause(errors.New(msg))
    }
    return fmt.Errorf("%s: %w", msg, err)
}

func Wrapf(err error, format string, args ...any) error {
    return Wrap(err, fmt.Sprintf(format, args...))
}

Honest trade-offs

  • No stack traces. Error 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.
  • Leaking is still possible if you're not careful. ErrNotFound.WithMsg(err.Error()) breaks the safety guarantee — the technical message becomes user-facing. The API prevents accidental leaks via Error(), not deliberate ones through carelessness. Discipline is still required at this specific point.
  • The conversion cost. Every technical error must be converted to a *Error at the service boundary. This is intentional friction — on a project with a hundred error sites, that's code to write and maintain.
  • Unwrap() exposes the cause to the full chain. errors.Is(err, sql.ErrNoRows) works from an upper layer — which means a handler layer can couple on a repo implementation detail if it chooses to. This is a conscious choice: the cause is accessible for code (logs, tests), but never via Error(). The sentinel taxonomy should be expressive enough that this coupling is never needed.

Conclusion

The real value of this package is not in the 70 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)