httperr

Error responses

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

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. httperr 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, on plain net/http.

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

The Problem/FieldError shape and the quick constructors (BadRequest, NotFound, Validation, ...) are on the Overview page; this page is about turning any error, not just a hand-built Problem, into that shape.

WriteError and NewWriteError

func WriteError(w http.ResponseWriter, r *http.Request, err error)              // default options
func NewWriteError(opts ...HandlerOption) func(http.ResponseWriter, *http.Request, error)

func WriteBadRequest(w http.ResponseWriter, r *http.Request, err error)

WriteError runs the mapping below and writes the resulting Problem as application/problem+json. NewWriteError builds a customized writer with HandlerOptions:

type HandlerOption func(*handlerConfig)

func WithExposeInternal(expose bool) HandlerOption // 5xx detail + stack/chain in the response, dev only!
func WithLogger(l *slog.Logger) HandlerOption      // logger for 5xx causes (default: slog.Default())

WriteError's signature deliberately matches oapi-codegen's strict-server ResponseErrorHandlerFunc hook, so the generated register.go wires it there directly: an error your handler returns goes through WriteError with no glue code in between. WriteBadRequest maps everything to 400, except an already-built Problem and the 413 body-limit error, because a parameter-parse or body-decode failure is never a 500; it matches oapi-codegen's RequestErrorHandlerFunc/ErrorHandlerFunc hooks, used where decoding, not your handler, produced the error.

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

The mapping

WriteError and WriteBadRequest 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 httperr/validate422 with per-field errors[] (the validator already returns a Problem)
*http.MaxBytesError (body over the configured 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 (via WithLogger's logger, slog.Default() otherwise) with the request's method, path and trace context; for an errs error, together with its structured chain and stack (see errs: Integration). 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 (errs.Frames/errs.Chain, see errs: API). In production the client never sees internals.

A recovered panic, turned into an *errs.Error with errs.NewPanic (its stack pointing at the panic site), renders and logs exactly like any other 500 once it reaches this mapping.

errs integration

The mapping's errs row is where httperr and errs meet: Code, Public and Status, whichever attributes the error was built or Defined with, become the Problem's code, detail and status fields directly, with no translation layer in between. This is why declaring a failure mode once, with errs.Define, is enough to make it render correctly everywhere it's returned: the Problem fields are a direct read of the errs attributes, not something a handler decides case by case.

Dev mode: whose setting is it?

WithExposeInternal is httperr's own option, but in a generated gpsystem project you never call it directly: server.Config's ExposeInternalErrors field (HTTP_EXPOSE_INTERNAL_ERRORS env variable) is the kit's setting, and server.Run passes it into the httperr writer it wires up for you. If you're using httperr standalone, without the kit, WithExposeInternal(true) is the switch to flip yourself, guarded by whatever env variable or build tag your own project uses; just keep it off in production, the same way the kit does, so internals go to the log and Sentry, never into a response.

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 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.

The spec side

The generated spec/typespec/shared/errors.tsp mirrors httperr.Problem (including FieldError), so frontend clients generated from the OpenAPI spec get exactly this shape; see Codegen pipeline. 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

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

Copyright © 2026