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. 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 chain | Response |
|---|---|
*httperr.Problem | rendered as-is (a copy, the sentinel is never mutated) |
a validation failure from httperr/validate | 422 with per-field errors[] (the validator already returns a Problem) |
*http.MaxBytesError (body over the configured 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
slog(viaWithLogger's logger,slog.Default()otherwise) with the request's method, path and trace context; for anerrserror, together with its structured chain and stack (see errs: Integration). 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 (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
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.
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
- RFC 9457 Problem Details: the
Problemstruct and the standard itself, see Design patterns. - Cascading
errors.Asmapping: Design patterns.
The official standard text: RFC 9457: Problem Details for HTTP APIs.
Related pages
- httperr: Overview: the
Problemshape and the quick constructors. - Validation: the 422 shape the mapping's validation row reads.
- errs: API and Error model: where the errors this page renders come from.
- The server core: how the kit wires
WriteError/WriteBadRequestinto the generated strict server.