Problem Details
RFC 9457, "Problem Details for HTTP APIs", is the standard shape for an error body, and the one an API gateway, a client generator or another team's service may already expect. Muzak's default envelope stays the default; one option switches the whole application to problems.
app := muzak.New(muzak.AppOptions{
Title: settings.AppName,
// Every error is answered as application/problem+json, and the OpenAPI
// document describes every error response that way.
ProblemDetails: &muzak.ProblemOptions{TypeBase: "https://errors.example.com/"},
})
{
"type": "https://errors.example.com/validation_error",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The request could not be validated.",
"instance": "urn:uuid:0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31",
"code": "validation_error",
"errors": [
{ "field": "name", "location": "body", "issue": "is required" },
{ "field": "limit", "location": "query", "issue": "must be a valid integer" }
],
"request_id": "0611f4b2-2f0a-7b57-9c1a-6e6a2e2f9b31"
}
The members
| Member | Carries |
|---|---|
type | TypeBase followed by the error code, path-escaped; about:blank when no base is set |
title | the status's reason phrase, such as Not Found, translated where the application translates it |
status | the HTTP status |
detail | the message the default envelope would carry, held to the same rule |
instance | urn:uuid: followed by the request identifier |
code | the stable classifier, such as not_found (extension) |
errors | every field that failed, as the envelope's details (extension, omitted when empty) |
request_id | the identifier that joins the response to the server's log (extension) |
The Go type is muzak.Problem, and the media type muzak.ProblemContentType.
instance names the occurrence by its request identifier rather than by the request's path. A
path can hold personal data, an email address or a name, that a problem report forwarded to a
ticket or a log aggregator should not repeat. Every identifier Muzak assigns is a UUID, so the
member is a URN of the registered uuid namespace; an application that assigns identifiers of its
own that are not UUIDs gets no instance.
code, errors and request_id are what the default envelope carries, so a client moving from
one format to the other loses nothing. Branch on code, or on type when a base is set; never on
title or detail, which are text for people and are translated.
TypeBase
muzak.ProblemOptions{TypeBase: "https://errors.example.com/"} // https://errors.example.com/not_found
muzak.ProblemOptions{TypeBase: "urn:example:errors:"} // urn:example:errors:not_found
muzak.ProblemOptions{} // about:blank
The code is appended as it is, so the base ends in whatever separates the two. A code with a
character a path segment cannot carry, as one set with WithCode("card declined") might, is
escaped: card%20declined. The RFC recommends serving documentation at each type URI; Muzak does
not, so publish it there if you name a base that resolves.
Left empty, every problem's type is about:blank, which the RFC defines as a problem described by
its status alone, and the title is then the status's reason phrase, as the RFC asks.
A base that is not an absolute URI, or that carries a query, a fragment, whitespace or a
character outside ASCII, is a build error: a client would resolve a relative one against wherever
it read the response, and a code appended after a ? or # would land in the wrong part.
The same promises as the envelope
The problem renderer decides nothing on its own. It asks DefaultErrorRenderer and reshapes what
that decided, so everything Error Handling says about the
envelope holds for a problem too:
- The same status and code for every error, and the same details for a validation failure.
- A server-side fault, a plain
error, aStatusCoderat500or above, or a panic, is answered with the fixed sentenceThe server could not complete the request.while the cause is logged and never sent. An*HTTPErrorkeeps the message it was written with, at any status. - The headers set before the error was rendered are kept:
Allowon a405,Retry-Afteron a429,WWW-Authenticateon a401,Vary,Set-Cookie. The body's own headers are reset and the response is sent withCache-Control: no-store.
Every error the application produces is a problem, not only those a route returns: a refused host,
a rate limit, a guard on a mounted handler, an unknown path, a method the documentation does not
answer, the 405 of a health endpoint, and a panic in middleware installed with App.Use, which
used to be answered with the fixed envelope whatever the renderer was. So is the 500 an
application that failed to build answers every request with, and the fixed 500 that stands in
when an error renderer's body cannot be encoded; neither has a request to translate for, so both
are in English.
The same goes for the features that refuse a request before any route runs. A state-changing
request a browser sends from another origin is refused by
Cross-Origin Requests with a 403 problem whose code is
cross_origin_request, and a session that cannot be saved fails its
request through the renderer, as a handler's error would.
Translation
detail is translated exactly as the envelope's message is (see
Internationalization). title is looked up at
muzak.status.<code>, falling back to the English phrase net/http knows the status by:
es:
muzak:
status:
404: "No encontrado"
422: "Entidad no procesable"
http:
404: "No se encontro el recurso solicitado."
The shipped locale carries the English titles of every status the framework classifies, and a test
keeps them equal to http.StatusText, so turning a store on changes no English title. The machine
members, type, code and status, are never translated.
The document
With ProblemDetails set, every error response the OpenAPI
document describes, the 422 of a route that binds input, the 429 of a rate-limited one, the
503 of a route with a Timeout, each WithResponseDoc and the default, is
application/problem+json with a $ref to the Problem component, and the ErrorResponse
component is no longer emitted. A response declared with WithResponseModel keeps its own model.
Composing with a renderer of your own
muzak.ProblemDetails(opts) is the renderer itself, so an application with errors of its own to
render can delegate the rest to it:
problems := muzak.ProblemDetails(muzak.ProblemOptions{TypeBase: "https://errors.example.com/"})
app := muzak.New(muzak.AppOptions{
ProblemDetails: &muzak.ProblemOptions{TypeBase: "https://errors.example.com/"},
ErrorRenderer: func(ctx *muzak.Context, err error) (int, any) {
var declined *billing.DeclinedError
if errors.As(err, &declined) {
ctx.SetHeader("Content-Type", muzak.ProblemContentType)
return http.StatusPaymentRequired, DeclinedProblem{ /* ... */ }
}
return problems(ctx, err)
},
})
When ErrorRenderer is set, it is used, and ProblemDetails still tells the document that errors
are problems. Set both, as above: installing muzak.ProblemDetails through ErrorRenderer alone
renders problems but leaves the document describing the envelope.
Limits
A few answers are written by net/http's file serving rather than by the renderer, and are plain text whatever the format:
- The
416thathttp.ServeContentsends for a range a file cannot satisfy, on a static file or frontend mount and for aFileResponse. - The
404or500a static file or frontend mount sends when a file it found cannot be opened or read a moment later, for instance because it was deleted in between. The cause is logged.
Where to go next
Error Handling covers which errors describe themselves and which stay opaque, and OpenAPI what else the document says about a route.