Concepts

Error model

RFC 9457 problem details everywhere, with errs errors carrying stacks, codes and client-safe messages underneath.

The gpsystem error model has two layers and a single principle: one error shape on the wire, one mapping in the code. Every error a gpsystem service returns (a validation failure, a 404, a panic, a database timeout) reaches the client as an RFC 9457 problem document with Content-Type: application/problem+json:

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/api/v1/shop/orders",
  "errors": [
    { "field": "quantity", "rule": "min", "message": "must be at least 1" }
  ]
}

The wire-facing shape is httperr.Problem; underneath, services and repositories return conventional Go errors. When built with the errs package, those carry a stack trace, a machine-readable code and a client-safe public message. This page describes the whole chain: the errs API, and how a repository error becomes an HTTP response, a log record and a Sentry issue.

The duality is expressed with attributes: Public and Status are the render side (what the client sees), Code, the wrap chain and the stack are the report side (what goes to the log/Sentry). Rendering is done not by the individual error but by a single central mapping.

Why two messages

An error has two audiences. The internal message (Error()) is for you: full detail, wrapped causes, the failing query. The public message is the only text a client may see. Separating the two means a stray err.Error() in a response can never leak internals: the httperr handler reads PublicOf, never Error().

The stack trace and the wrap chain go to the log on every 5xx. They reach an HTTP response only in dev mode (HTTP_EXPOSE_INTERNAL_ERRORS=true), as the Problem's stack and chain extension members; in production they live exclusively in the log.

The errs API

import "github.com/gp-system/gpsystem/errs"

errs adds three things to a plain fmt.Errorf chain: the location of every wrap plus a full stack trace from the point of origin, a machine-readable code, and a client-safe public message. Error() output stays byte-identical to the "pkg: op: %w" convention, and errors.Is / errors.As / errors.Join work unchanged. It can be adopted gradually, boundary by boundary.

func New(msg string, attrs ...Attr) error
func Errorf(format string, args ...any) error            // %w works (single and multi)
func Wrap(err error, msg string, attrs ...Attr) error    // returns nil when err is nil
func Wrapf(err error, format string, args ...any) error  // returns nil when err is nil
func NewPanic(rec any, attrs ...Attr) error              // from a recovered panic; the stack points at the panic site

Wrap and Wrapf return nil for a nil input, so they are safe in one-line returns: return errs.Wrap(tx.Commit(ctx), "pg: commit").

NewPanic builds an error from a recovered panic value, capturing the stack at the recovery point, which, thanks to the unwinding, still contains the panic site. An error-typed panic value becomes the cause (errors.Is/As work on it); any other value is formatted as panic: %v. The kit's Fiber and chi recoverers call it for you, so a panic is logged with the same structured chain and stack as any other error.

Attributes

Attached at construction time:

errs.Code("shop_api_order_insert_failed") // machine-readable, stable across releases
errs.Public("Saving the order failed.")   // the only text a client may see
errs.Status(http.StatusConflict)          // HTTP status httperr maps this error to (default 500)
errs.With("order_id", id)                 // allowlisted metadata: pass scalars, not whole structs

Code identifies the failure mode in the log, in Sentry (the issue title and grouping fingerprint, see Sentry) and in the RFC 9457 response's code member. Pick a stable snake_case name: <module>_<action>_failed for unexpected operational failures (e.g. shop_api_order_insert_failed), <module>_<condition> for expected business errors (e.g. shop_out_of_stock).

Accessors

They walk the chain from the outside in, the outermost set value wins:

errs.CodeOf(err) string       // the first code in the chain, or ""
errs.PublicOf(err) string     // the first public message in the chain, or ""
errs.StatusOf(err) int        // the first HTTP status in the chain, or 0 (httperr falls back to 500)
errs.Chain(err) []errs.Step   // per-level message, wrap site and attrs, outermost first
errs.Frames(err) []errs.Frame // full stack from the point of origin

Stack semantics

  • Capture once. The full stack is captured only at the deepest errs error in a chain. Wrapping an error that already carries a stack records the new wrap site (visible in Chain) but does not re-capture the stack: Frames always points at the point of origin.
  • Depth. The stack is bounded at 32 frames: the kit's request paths are ~15–20 frames deep, so this covers them while keeping log records small.
  • Joined errors. With errors.Join, if any branch already carries a stack, no new one is captured: one stack per chain keeps logs lean. The other branch's origin may be lost; a deliberate trade-off.
  • Goroutines. The stack is captured where the error is constructed: an error created in a goroutine carries the goroutine's frames, not the parent's.
  • Lazy resolution. Program counters resolve to file/line/function only when the error is actually logged.

Sentinels stay errors.New

A package-level errs.New sentinel would freeze an init-time stack and share mutable attrs across requests. Define a bare sentinel with stdlib errors.New and wrap it per call:

var ErrNotFound = errors.New("shop: product not found")

return errs.Wrap(ErrNotFound, "product: get") // errors.Is(…, ErrNotFound) still holds

errs.Define: named failure modes

The common case, though, is a sentinel that should also carry a code, a public message or a status. That is what a Definition is for: it is immutable and stackless, so it is safe as a package-level var, while every occurrence still gets its own call-site stack:

func Define(code string, attrs ...Attr) *Definition

func (d *Definition) New(msg string, attrs ...Attr) error
func (d *Definition) Newf(format string, args ...any) error
func (d *Definition) Wrap(err error, msg string, attrs ...Attr) error   // nil-safe
func (d *Definition) Wrapf(err error, format string, args ...any) error

errors.Is(err, ErrSomething) matches any error instantiated from the definition, no matter how deeply wrapped: the same identity semantics as a stdlib sentinel, with the code/message/status set once. Per-call attrs override the definition's defaults for that occurrence. Define panics when Status is not a 4xx/5xx code (a programmer error that surfaces at init time, not during a request).

The status is decided not at the call site but at the failure mode's declaration, once.

The shop example: out of stock

The expected business error (ErrOutOfStock) is declared where it naturally originates: in the module-level repository. Handlers never import the repository package, though, so the sentinel is re-exported up the chain: core passes it on (var ErrOutOfStock = repository.ErrOutOfStock), and the surface service does too, so handlers and services only ever check their own surface's service package. The surface-specific wrap error (ErrPlaceOrder) lives in the surface service:

internal/modules/shop/repository/errors.go
package repository

import (
    "net/http"

    "github.com/gp-system/gpsystem/errs"
)

var ErrOutOfStock = errs.Define("shop_out_of_stock",
    errs.Public("The product is out of stock."),
    errs.Status(http.StatusConflict))
internal/modules/shop/surfaces/api/service/errors.go
package service

import (
    "github.com/gp-system/gpsystem/errs"

    "github.com/acme/shop/internal/modules/shop/core"
)

// Re-exported from core (which re-exports it from the repository): handlers
// only ever import their own surface's service package.
var ErrOutOfStock = core.ErrOutOfStock

var ErrPlaceOrder = errs.Define("shop_api_place_order_failed",
    errs.Public("Placing the order failed, please try again later."))

The error originates in the repository (the stack is captured here), and every layer wraps it upward with its own context:

internal/modules/shop/repository/orders.go
func (r *Orders) DecrementStock(ctx context.Context, productID string, qty int) error {
    tag, err := r.db.Exec(ctx, decrementStockSQL, productID, qty)
    if err != nil {
        return errs.Wrap(err, "orders: decrement stock",
            errs.With("product_id", productID))
    }
    if tag.RowsAffected() == 0 {
        return ErrOutOfStock.New("orders: decrement stock",
            errs.With("product_id", productID))
    }
    return nil
}
internal/modules/shop/surfaces/api/service/service.go
func (s *Service) PlaceOrder(ctx context.Context, in PlaceOrderInput) error {
    err := s.core.PlaceOrder(ctx, in) // transaction + stock decrement + outbox in core
    if errors.Is(err, ErrOutOfStock) {
        return err // expected business error: passes through unchanged
    }
    return ErrPlaceOrder.Wrap(err, "shop: place order") // nil-safe: nil in, nil out
}

The handler just returns what it gets, rendering is not its job:

internal/modules/shop/surfaces/api/http/handler.go
func (h *Handler) PlaceOrder(ctx context.Context, req gen.PlaceOrderRequest) (gen.PlaceOrderResponse, error) {
    if err := h.svc.PlaceOrder(ctx, toPlaceOrder(req)); err != nil {
        return nil, err
    }
    return gen.PlaceOrder201Response{}, nil
}

And then the three audiences see three things:

The client (409, because ErrOutOfStock is status-mapped):

{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "detail": "The product is out of stock.",
  "instance": "/api/v1/shop/orders",
  "code": "shop_out_of_stock"
}

The log: nothing. A status-mapped 4xx is expected client behavior, not an incident. It produces no request failed log line. Sentry: also nothing, for the same reason.

If, however, Postgres drops out underneath, the chain exits through ErrPlaceOrder as a 500: the client gets only the "Placing the order failed..." detail and the shop_api_place_order_failed code, the log gets the full chain and stack, and Sentry gets an issue titled shop_api_place_order_failed, grouped by code and linked to the trace.

The mapping

The central mapping (httperr.NewErrorHandler on Fiber; httperr.WriteError on chi; wired into the generated server's error hooks and the router's panic/404/405 handlers) decides in this order:

ErrorResponse
*httperr.Problemrendered as-is
a validation error from bind422 with per-field errors[] (see Validation)
*fiber.BindError (malformed body/params)400
*fiber.Errorits own status code
*http.MaxBytesError (body over the limit)413
an errs error in the chainerrs.StatusOf (default 500), detail = the public message, code member = the code
anything else500, no detail

On a 5xx the underlying error goes to slog with the request's trace context. The client gets no internals. In dev mode (HTTP_EXPOSE_INTERNAL_ERRORS=true) the detail is filled in for debugging, and when the chain carries an errs error the response also gets stack and chain extension members. The full wire-side picture is on the Error responses page.

For quick 4xx cases the httperr constructors remain available (httperr.NotFound(...), httperr.Conflict(...), httperr.Validation(...)), but as soon as a failure mode has a name, errs.Define is the better home: recognition (errors.Is) and rendering (status, code, message) live in a single declaration, not in an if branch in a handler.

Log output

*errs.Error implements slog.LogValuer, so slog.Any("error", err) emits a structured group instead of one flattened string. httperr does this automatically on every 5xx:

"error": {
  "msg": "shop: place order: orders: decrement stock: connection refused",
  "code": "shop_api_place_order_failed",
  "public": "Placing the order failed, please try again later.",
  "chain": [
    { "msg": "shop: place order",
      "file": "api/service/service.go", "line": 42, "function": "service.(*Service).PlaceOrder",
      "code": "shop_api_place_order_failed" },
    { "msg": "orders: decrement stock",
      "file": "repository/orders.go", "line": 31, "function": "repository.(*Orders).DecrementStock",
      "meta": { "product_id": "prod_42" } },
    { "msg": "connection refused" }
  ],
  "stack": [
    { "file": "repository/orders.go", "line": 31, "function": "repository.(*Orders).DecrementStock" },
    { "file": "api/service/service.go", "line": 42, "function": "service.(*Service).PlaceOrder" },
    { "file": "api/http/handler.go", "line": 55, "function": "http.(*Handler).PlaceOrder" }
  ]
}

chain is one object per wrap level (the level's own message, the wrap site, and the code/metadata set there), ending in the root cause. stack is the full call path from the point of origin, intermediate non-wrapping calls included.

Log or return, not both. httperr already logs every 5xx with the full chain and stack; if you log it yourself too, you get duplicate records.

LogValue only runs on the outermost error. If your last wrap of an errs error is a plain fmt.Errorf, slog sees a string and the structure is lost: at a boundary, make errs.Wrap the outermost wrap. (httperr defends against this: it logs a separate cause attribute when it finds an errs error buried under plain wrappers.)

Sentry

*errs.Error exposes a StackTrace() []uintptr method (the shape sentry-go recognizes via reflection), so the kit's Sentry integration extracts the stack with no extra wiring. Before sending, the integration retitles the exception with the outermost error's Code (when set), so the code is not just the response's code member but also the Sentry issue's title and grouping fingerprint: one failure mode = one issue, regardless of message-text changes.

The spec side

The generated spec/typespec/shared/errors.tsp mirrors httperr.Problem (including FieldError), so frontend clients generated from the OpenAPI get exactly this shape. If you extend one, extend the other. The dev-only stack and chain members are deliberately not mirrored in errors.tsp: they never appear in production, so no client can build on them.

Patterns used

This page is the full write-up of two patterns also listed in the Design patterns catalog: the sentinel definition with a custom Is (errs.Define) and lazy, once-per-chain stack capture (errs/stack.go). The cascading errors.As mapping (on the httperr side) and the RFC 9457 shape live on the Error responses page.

Copyright © 2026