Error responses
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())
// chix.Run wires it into the panic/404/405 handlers, the generated
// register.go into the strict server's error hooks:
func NewWriteError(opts ...HandlerOption) func(http.ResponseWriter, *http.Request, error)
func WriteError(w http.ResponseWriter, r *http.Request, err error) // with default options
// For binding/decoding hooks, where every error is a client error:
func WriteBadRequest(w http.ResponseWriter, r *http.Request, err error)
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 chain | Response |
|---|---|
*httperr.Problem | rendered as-is (a copy, the sentinel is never mutated) |
| a validation failure from the validator | 422 with per-field errors[] (the validator already returns a Problem) |
*fiber.BindError (malformed body/query/header) | 400 |
*fiber.Error | its own status code |
*http.MaxBytesError (body over BODY_LIMIT) | 413 |
an errs error | errs.StatusOf (500 when unset); detail = errs.PublicOf, code = errs.CodeOf |
| anything else | 500, with no detail |
Two cross-cutting rules complete it:
- Every 5xx is logged: the underlying error goes to
slogwith the request's method, path and trace context; for anerrserror, together with its structured chain and stack. A status-mapped 4xxerrserror (e.g. a definition declared witherrs.Status(404)) produces no log line and no Sentry event: expected client behavior is not an incident. - Dev mode shows more: under
WithExposeInternal(true)thedetailof unknown errors is filled in, and when the chain carries anerrserror, the response gainsstackandchainextension 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
detailis the public message: the internalError()text (query, wrap chain) never lands here, - the
codeis 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.
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
- RFC 9457 Problem Details: the
Problemstruct and the standard itself, see Design patterns. - Cascading
errors.Asmapping inmapProblem: Design patterns.
The official standard text: RFC 9457: Problem Details for HTTP APIs.
The chi engine
server/chix, a chi router over the standard net/http with the kit's error handling and telemetry wired in, run with graceful shutdown.
Validation
Struct-tag-based request validation with go-playground/validator, at bind time on Fiber, as strict middleware on chi, with 422 problem responses.