HTTP & Routing

Error responses

The httperr package, RFC 9457 problem+json for every error, through one central mapping, on both engines.

Every error a gpsystem service puts on the wire (a validation failure, a 404, a panic, a database timeout) is an RFC 9457 problem document with Content-Type: application/problem+json. The httperr package guarantees that: it is the wire-facing error shape and the single place where a Go error becomes an HTTP response. Building errors (stack, code, public message) is covered by the errs package and the error model; this page covers the rendering side.

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

The Problem shape

type Problem struct {
    Type     string       `json:"type"`
    Title    string       `json:"title"`
    Status   int          `json:"status"`
    Detail   string       `json:"detail,omitempty"`
    Instance string       `json:"instance,omitempty"`
    Code     string       `json:"code,omitempty"`   // machine-readable error code (extension member)
    Errors   []FieldError `json:"errors,omitempty"` // per-field validation errors

    // Dev-only debug members: only under WithExposeInternal(true), and only
    // when the error carries an errs error. Always empty in production.
    Stack []errs.Frame `json:"stack,omitempty"`
    Chain []errs.Step  `json:"chain,omitempty"`
}

type FieldError struct {
    Field   string `json:"field"`
    Rule    string `json:"rule,omitempty"`
    Message string `json:"message"`
}

Problem implements error: you can return it directly from a handler or service, and it is rendered as-is at the end of the handler chain. When Instance is empty, the renderer stamps it with the request path. The Stack/Chain members are deliberately not mirrored by the generated shared/errors.tsp contract: clients must not depend on them, because they never appear in production.

Constructors

httperr.New(status, title, detail) // title defaults to the HTTP status text
httperr.BadRequest(detail)         // 400        httperr.Unauthorized(detail) // 401
httperr.Forbidden(detail)          // 403        httperr.NotFound(detail)     // 404
httperr.Conflict(detail)           // 409        httperr.Internal(detail)     // 500
httperr.Validation(fieldErrs...)   // 422, with errors[]

They are meant for quick, anonymous 4xx cases: return httperr.NotFound("no such product") in a handler is perfectly fine. As soon as an error mode has a name, a code or more than one occurrence, errs.Define is the better home: there, recognition and rendering live in a single declaration.

Package-level Problem sentinels (var ErrX = httperr.NotFound(...)) are safe: the renderers take a copy before stamping Instance or dev members into it, so no state leaks across requests.

The two entry points

The mapping is one; what differs per engine is only how it is hooked in. On Fiber, errors flow into the central ErrorHandler; net/http has no such thing, so there the generated server code's error hooks call the writer functions.

// fiberx.Run wires it into fiber.Config.ErrorHandler automatically:
func NewErrorHandler(opts ...HandlerOption) fiber.ErrorHandler
var ErrorHandler fiber.ErrorHandler // with default options, for manual wiring

// Options:
httperr.WithExposeInternal(true) // 5xx detail + stack/chain in the response, dev only!
httperr.WithLogger(l)            // logger for 5xx causes (default: slog.Default())

WriteError's signature deliberately matches oapi-codegen's ResponseErrorHandlerFunc hook, and WriteBadRequest the RequestErrorHandlerFunc/ErrorHandlerFunc hooks: the generated register.go wires them exactly like that. WriteBadRequest maps everything to 400 (except an already-built Problem and the 413 body-limit error), because a parameter-parse failure is never a 500. WriteError would misleadingly blame the server there.

Both engines pass the ExposeInternalErrors setting (HTTP_EXPOSE_INTERNAL_ERRORS) from server.Config to their entry point: you only call WithExposeInternal yourself when building the handler manually.

The mapping

Both entry points run the same mapping, in this order:

Error in the chainResponse
*httperr.Problemrendered as-is (a copy, the sentinel is never mutated)
a validation failure from the validator422 with per-field errors[] (the validator already returns a Problem)
*fiber.BindError (malformed body/query/header)400
*fiber.Errorits own status code
*http.MaxBytesError (body over BODY_LIMIT)413
an errs errorerrs.StatusOf (500 when unset); detail = errs.PublicOf, code = errs.CodeOf
anything else500, with no detail

Two cross-cutting rules complete it:

  • Every 5xx is logged: the underlying error goes to slog with the request's method, path and trace context; for an errs error, together with its structured chain and stack. A status-mapped 4xx errs error (e.g. a definition declared with errs.Status(404)) produces no log line and no Sentry event: expected client behavior is not an incident.
  • Dev mode shows more: under WithExposeInternal(true) the detail of unknown errors is filled in, and when the chain carries an errs error, the response gains stack and chain extension members. In production the client never sees internals.

A recovered panic arrives here as an errs error on both engines (its stack points at the panic site), so it renders and logs exactly like any other 500.

The shop: what the client sees when stock runs out

The PlaceOrder service returns the ErrOutOfStock definition (errs.Status(409), errs.Public(...); see the declaration on the Error model page). By the time the POST /api/v1/shop/orders response goes out:

{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "detail": "The product is out of stock.",
  "instance": "/api/v1/shop/orders",
  "code": "shop_api_out_of_stock"
}
  • the 409 came from errs.StatusOf,
  • the detail is the public message: the internal Error() text (query, wrap chain) never lands here,
  • the code is stable and machine-readable: the frontend can branch on it (if (body.code === "shop_api_out_of_stock")), independent of message-text changes,
  • log and Sentry: nothing (a status-mapped 4xx).

If the same call fails on a database error instead, the client gets a 500 with the shop_api_place_order_failed code and the generic public message, while the full chain and stack land in the log and in Sentry.

Render and report in a single mapping

What the client sees (render) and what goes to the log/Sentry (report) lives in one central mapping: the render side is the table above (its input being errs.Status/Public/Code), the report side is the automatic 5xx logging and the Sentry integration. You don't write any per-error render logic: the error mode's declaration (errs.Define) carries everything the mapping works from.

Don't bypass the mapping with hand-written c.Status(500).JSON(...) / w.WriteHeader(500) responses in handlers. Return an error instead: that keeps the response shape, the logging and the Sentry reporting consistent, and your tests see the same thing the client does.

Patterns used

The official standard text: RFC 9457: Problem Details for HTTP APIs.

Copyright © 2026