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 built with errs, a standalone, stdlib-only module (github.com/gp-system/errs). Those errors carry a stack trace, a machine-readable code and a client-safe public message. errs works the same way with or without the framework, in any Go program; this page stays at the level of the whole pipeline. For the full errs API (New, Wrap, Define, attributes, accessors, stack semantics), see errs: Overview and errs: API.

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, and four different modules each own one link of the chain:

WhoDoes whatPage
errsbuilds and wraps errors: stack, code, public messageerrs: Overview
httperrrenders the error to an HTTP problem+json responseError responses
logxlogs the error as a structured recordLogging
telemetry/sentryxreports the error to Sentry, grouped by codeSentry

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 mapping reads errs.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 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: the module root package 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/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/errs"

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

// Re-exported from the module root (which re-exports it from the
// repository): handlers only ever import their own surface's service package.
var ErrOutOfStock = shop.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.shop.PlaceOrder(ctx, in) // transaction + stock decrement + outbox in the module root
    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 (see Logging), and Sentry gets an issue titled shop_api_place_order_failed, grouped by code and linked to the trace (see Sentry).

The mapping

The central mapping (httperr's renderer, 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)
a request-decoding error (malformed body/params)400
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, including the exact per-engine hooks, 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, 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. The full mechanics (why chain and stack look like this, and the LogValue/plain-wrap pitfall) are on Logging.

Sentry

*errs.Error exposes a StackTrace() []uintptr method (the shape sentry-go recognizes via reflection), so telemetry/sentryx 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 not mirrored in errors.tsp: they never appear in production, so no client can build on them.

Patterns used

This page is the pipeline view of two patterns cataloged in more depth in Design patterns: the sentinel definition with a custom Is (errs.Define, full API on errs: API) and lazy, once-per-chain stack capture. The cascading errors.As mapping (on the httperr side) and the RFC 9457 shape live on the Error responses page.

Where to next

  • errs: Overview: install and the standalone-usage contract.
  • errs: API: the full New/Wrap/Define/attributes/accessors walkthrough.
  • Error responses: the wire-facing Problem shape and the per-engine mapping.
  • Logging and Sentry: what happens to an error after it's logged or reported.
Copyright © 2026