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:
- Safe by default:
Error()returns only the user-facing message. - No
Unwrap(): the introspection chain is intentionally broken.errors.Is()never traverses the internal cause. A deliberate security choice. slog.LogValuer: technical details are exposed only through structured logs.- Immutable builder pattern:
WithMsg(),WrapCause()always return a new instance — sentinels are never mutated. - Centralized HTTP mapping: a single point maps
ErrorKindvalues 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.
AppErrdoes 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. -
WrapCausecan 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
AppErrat 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 testerrors.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.