Error Handling
Every failure renders as one envelope, carrying a machine-readable code, a message safe to disclose, the status, per-field details and the request identifier that ties the response to the server's log.
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "name", "location": "body", "issue": "is required" },
{ "field": "limit", "location": "query", "issue": "must be a valid integer" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
The Go types are muzak.ErrorResponse, muzak.ErrorBody and muzak.ErrorDetail, so a
client written in Go can decode the envelope with the same definitions the server writes
it from. The HTTP client's JSON helpers and
typed endpoint calls do that for you, reporting a failure
as a *muzak.RemoteError whose Code, Message, Details and RequestID come from the
envelope.
Two kinds of error
Muzak divides every error a handler or a dependency returns into two groups.
An error that describes itself reaches the client as written. That means an
*HTTPError, a *ValidationError, or a StatusCoder below 500, whose own Error() text
is what is sent (see Your own error types).
Everything else becomes an opaque 500 whose message and code are fixed constants,
with the real cause written to the log and never transmitted. A nil dereference, a
database driver error or a wrapped file path cannot leak internal state through a
response.
{
"error": {
"code": "internal_error",
"message": "The server could not complete the request.",
"status": 500
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
Both the code and the message are fixed constants. Nothing derived from the underlying error appears anywhere in the response.
Returning an HTTP error
NewHTTPError takes the status and the message the client will read.
return schemas.ItemOut{}, muzak.NewHTTPError(http.StatusNotFound, "Item not found")
{
"error": {
"code": "not_found",
"message": "Item not found",
"status": 404
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
The status decides the classifier, 404 becoming not_found, and the message is
transmitted verbatim. NewHTTPErrorf builds that message with a format string:
return schemas.ItemOut{}, muzak.NewHTTPErrorf(http.StatusBadRequest,
"%q is not a supported currency", in.Currency)
{
"error": {
"code": "bad_request",
"message": "\"XBT\" is not a supported currency",
"status": 400
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
Because the formatted result is sent to the client, keep internal state out of it. Use
Wrap for the parts that should stay server-side.
*HTTPError composes:
return Out{}, muzak.PaymentRequired("the card was declined").
WithCode("card_declined").
WithDetails(muzak.ErrorDetail{
Field: "payment_method",
Location: "body",
Issue: "was declined by the issuer",
}).
Wrap(err)
{
"error": {
"code": "card_declined",
"message": "the card was declined",
"status": 402,
"details": [
{ "field": "payment_method", "location": "body", "issue": "was declined by the issuer" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
What Wrap holds appears nowhere in that response. It is written to the log, next to the
same request identifier the client was given.
| Method | What it does |
|---|---|
WithCode(code) | Overrides the classifier, for a status too coarse on its own |
WithDetails(...) | Attaches per-field details, which appear in details |
Wrap(err) | Records a cause that is logged and visible to errors.Is and errors.As, and never sent |
WithMessageKey(key, args...) | Names a translation for the message, keeping the one already written as the fallback |
WithDetails, WithCode, WithMessageKey and Wrap each return a modified copy and
leave the receiver untouched. They used to edit the error in place, so a package-level error
decorated per request handed one request's details to the next response, grew them with every
request and raced between concurrent ones. Use the returned value, which every use above
already does. A call whose result is discarded, such as err.WithCode("x") on a line of its
own, no longer has any effect.
Returning one from a handler, a guard or a provider short-circuits the request: nothing further down the chain runs, and the status, code and message become the response.
Errors in another language
Every sentence above is translated when the application has a locale configured, and none
of the constructors change. muzak.Forbidden("") takes the standard sentence for its
status, and that sentence is what gets looked up.
es:
muzak:
http:
403: "No tienes acceso a este recurso."
A message you write yourself is your own words, and is never replaced by a translation of something else. Name a translation for it explicitly when you want one:
return muzak.Forbidden("only editors may do that").
WithMessageKey("errors.access.denied")
The code is never translated. Clients branch on it, so a locale file that reworded one would break every client reading it. See Internationalization.
Errors by name
There is a constructor for each outcome worth a name of its own, so returning the right response is not a matter of remembering the right number:
return schemas.UserOut{}, muzak.NotFound("no user goes by that name")
{
"error": {
"code": "not_found",
"message": "no user goes by that name",
"status": 404
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
| Constructor | Status | Code |
|---|---|---|
BadRequest(message) | 400 | bad_request |
Unauthorized(message) | 401 | unauthorized |
PaymentRequired(message) | 402 | payment_required |
Forbidden(message) | 403 | forbidden |
NotFound(message) | 404 | not_found |
MethodNotAllowed(message) | 405 | method_not_allowed |
NotAcceptable(message) | 406 | not_acceptable |
RequestTimeout(message) | 408 | request_timeout |
Conflict(message) | 409 | conflict |
Gone(message) | 410 | gone |
PreconditionFailed(message) | 412 | precondition_failed |
PayloadTooLarge(message) | 413 | payload_too_large |
UnsupportedMediaType(message) | 415 | unsupported_media_type |
UnprocessableEntity(message) | 422 | validation_error |
TooManyRequests(message) | 429 | too_many_requests |
InternalServerError(message) | 500 | internal_error |
NotImplemented(message) | 501 | not_implemented |
BadGateway(message) | 502 | bad_gateway |
ServiceUnavailable(message) | 503 | service_unavailable |
GatewayTimeout(message) | 504 | gateway_timeout |
Every one of them returns an *HTTPError, so Wrap, WithCode and WithDetails chain
onto any of them, and a status without a name of its own is still NewHTTPError's to
produce.
The standard sentence
Passing an empty message uses the standard wording for that status, which is what a framework with one exception per status gives you when you raise it with no argument:
return schemas.UserOut{}, muzak.Forbidden("")
{
"error": {
"code": "forbidden",
"message": "You do not have access to this resource.",
"status": 403
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
Server errors say what happened, not why
Returning a 5xx by name is a deliberate act, so unlike an unexpected fault its message
reaches the client. The cause belongs in Wrap, which does not:
return schemas.PriceOut{}, muzak.BadGateway("the pricing service is not answering").
Wrap(err) // dial tcp 10.0.0.7:5432: connect: connection refused
{
"error": {
"code": "bad_gateway",
"message": "the pricing service is not answering",
"status": 502
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
The address, the port and the driver's wording stay in the log.
Translating domain errors
Keep HTTP out of your store or service layer, and translate at the edge.
var (
// ErrItemNotFound reports a lookup for an item that does not exist.
ErrItemNotFound = errors.New("item not found")
// ErrItemExists reports a create that collides with an existing item.
ErrItemExists = errors.New("item already exists")
)
func ReadItem(ctx *muzak.Context, in schemas.ItemParams) (schemas.ItemOut, error) {
store := muzak.From[*core.ItemStore](ctx)
item, err := store.Get(in.ID)
if err != nil {
return schemas.ItemOut{}, asHTTPError(err)
}
return schemas.ItemOut{ID: item.ID, Name: item.Name}, nil
}
Your own error types
Implement StatusCoder and your type becomes a first-class citizen of the error pipeline
without depending on *HTTPError.
type QuotaExceeded struct {
Plan string
}
func (e *QuotaExceeded) Error() string { return "quota exceeded for plan " + e.Plan }
func (e *QuotaExceeded) HTTPStatus() int { return http.StatusPaymentRequired }
{
"error": {
"code": "payment_required",
"message": "quota exceeded for plan free",
"status": 402
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
Three things follow from that. The code is derived from the status with CodeForStatus, so
reach for *HTTPError and WithCode when you want a classifier of your own.
Below 500, the message is the Error() text of the StatusCoder value itself, so a type that
implements it has declared that text safe to disclose. Keep file paths, driver messages and
query fragments out of it. It is the value errors.As finds in the chain, never the text of an
error wrapping it: context added on the way up with fmt.Errorf("...: %w", err) (a query, a
connection string, a token) does not reach the response.
From 500 up, the client gets the same fixed sentence as for any other error, and the error is
logged. A 5xx whose message is meant for the client is an *HTTPError, as
Server errors say what happened, not why shows.
Error codes
The code member is what clients should branch on, rather than on the status or the
message text.
| Constant | Value |
|---|---|
CodeBadRequest | bad_request |
CodeUnauthorized | unauthorized |
CodePaymentRequired | payment_required |
CodeForbidden | forbidden |
CodeNotFound | not_found |
CodeMethodNotAllowed | method_not_allowed |
CodeNotAcceptable | not_acceptable |
CodeRequestTimeout | request_timeout |
CodeConflict | conflict |
CodeGone | gone |
CodePreconditionFailed | precondition_failed |
CodePayloadTooLarge | payload_too_large |
CodeUnsupportedMediaType | unsupported_media_type |
CodeValidationError | validation_error |
CodeTooManyRequests | too_many_requests |
CodeNotImplemented | not_implemented |
CodeBadGateway | bad_gateway |
CodeServiceUnavailable | service_unavailable |
CodeGatewayTimeout | gateway_timeout |
CodeInternalError | internal_error |
CodeClientError | client_error |
muzak.CodeForStatus(status) returns the default code for a status: a recognised status
gets its specific classifier, any other 4xx becomes client_error, and everything else
becomes internal_error.
Validation failures
A *ValidationError renders as 422 classified validation_error, with one entry in
details per offending field, so a client learns about every mistake at once instead of
one per round trip. Binding failures and rule failures merge into the same envelope, each
carrying the location the binder established.
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "username", "location": "body", "issue": "is reserved" },
{ "field": "email", "location": "body", "issue": "must be a valid email address" },
{ "field": "password", "location": "body", "issue": "must be at least 12 characters" },
{ "field": "limit", "location": "query", "issue": "must be between 1 and 100" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
location is one of path, query, header, cookie or body. See
Validation for the rules that produce these.
A 422 never quotes the client's own value, or a parser's own error, back: an entry of a
repeated parameter is named by its position (entry 2 must be a valid integer), and a value a
TextUnmarshaler refuses reads is not in the expected format unless it returns an
*HTTPError with wording of its own. A report holds at most 100 failures and ends with a
detail of kind too_many_problems when there were more, and a model nested past 32 levels is
refused with a detail of kind too_deep.
A body member is named by its full path, addr.zip or items[1].zip, the way a validation
failure names it. An integer outside its type's range reads must be between -128 and 127
rather than must be a valid integer, in a parameter and in a body alike, and a query, header
or form parameter that holds one value but was sent more than once reads
must be given only once. A body type the decoder cannot use at all is the application's
fault rather than the client's, so it is a 500 with the reason logged, not a 422.
Failures the framework reports on its own
| Status | When |
|---|---|
401 | A route that enforces a security scheme built by JWTBearer or APIKeyVerifier received no valid credential. A bearer token scheme sends a WWW-Authenticate challenge. See Authentication |
403 | The credential was valid but lacks a scope the route requires, with an insufficient_scope challenge for a bearer token; or AppOptions.CrossOriginProtection refused a state-changing request a browser sent from another origin, with the code cross_origin_request (muzak.CodeCrossOriginRequest) |
404 | No route matched the path, and no frontend claimed it |
405 | The path exists but not for that method. The response carries Allow. The documentation and the health endpoints answer a method other than GET or HEAD the same way, with Allow: GET, HEAD |
413 | The request body exceeded the route's limit: refused from its declared Content-Length before any of it is read, or while it was being read when no length was declared |
415 | A body arrived under a media type the route cannot decode, or with no Content-Type at all |
421 | AppOptions.AllowedHosts does not list the request's host, with the code misdirected_request (muzak.CodeMisdirectedRequest) |
422 | Binding or validation failed |
429 | A rate limit was exceeded. The response carries Retry-After and the RateLimit headers |
503 | The connection or stream budget is full, a rate limit storage could not answer, a security scheme could not judge a credential yet (a JWT key set not fetched, an API key Lookup that failed), or the route's Timeout passed before the response started, which also sends Retry-After: 1 (see Timeouts) |
500 | A panic, or any error that does not describe itself. An application that failed to build answers every request with it, and logs the reason once |
Each of these is rendered by the error renderer, as an error a handler returned is. Two 500s
cannot reach a renderer of your own: the one an application that failed to build answers, and
the one a panic in middleware outside the router produces. Both are written in the envelope
above, or as a problem when AppOptions.ProblemDetails is set.
Panics
A panic escaping a handler or a dependency is caught, logged with its stack trace, the
request identifier, the method and the route, and answered with the generic 500 above.
Nothing derived from the panic value reaches the client: a panic often carries a pointer
address, a SQL fragment or a file path, and a stack trace maps out the server's internals.
http.ErrAbortHandler is re-panicked rather than swallowed, because net/http uses it to
abort a response deliberately.
The panic value is rendered with fmt and cut to 4 KiB in the log, with a ...(truncated)
marker, so a panic message built from a request's input cannot make a log line as large as the
input. The panic attribute is a string in the JSON format for every kind of value. A panic in
a middleware is logged as a 500 in the access log too.
A failure after the response started
Once the first byte of a response has been written, the status line is gone and there is no
error response left to send. A panic, or an error returned after that point, is logged once and
the connection is aborted: Muzak panics with http.ErrAbortHandler, which net/http
turns into a failed transfer without logging it again. Before, the response ended cleanly and a
truncated export looked complete. The access log marks such a request aborted=true.
A Flush counts as the start of the response even when it came before the first Write,
because it sends an implicit 200. A handler or middleware that flushes early and then fails
therefore aborts the connection, and SetStatus after the flush is refused and logged.
Hijacked connections, WebSocket routes and event stream routes are unchanged. Code that calls
App.ServeHTTP or Recovery directly, for instance in a test, should expect
http.ErrAbortHandler in this case.
Cleaning up after a failure
A provider declared with muzak.Acquire returns a Release beside its value, and the release
runs exactly once on every way the request ends (success, a returned error, a panic, a later
refusal, the client going away) and is told whether the request failed. muzak.Transaction
is built on it: the request's *sql.Tx is committed only when the handler and everything after
it succeeded, and rolled back otherwise. For a buffered response the releases run after the
result is encoded and before anything is written, so a release that fails, such as a commit,
replaces the success with its error, rendered like a handler's. Once the response has started,
a release error is treated as a failure after the response started, above. See
Dependencies.
What an error response carries
An error response replaces whatever the handler was building, so it drops the headers set for
the response it replaces: Content-Type, Content-Length, Content-Disposition,
Content-Encoding, Content-Range, ETag, Last-Modified, Cache-Control and Expires, and
the ones that describe a body that was never sent (Content-Digest, Repr-Digest, Digest,
Content-MD5, Accept-Ranges, Trailer and Content-Location). Location goes too, and is
kept only when the error itself answers with a 3xx. Content-Language is reset to the
request's locale. Vary, Retry-After, Allow, WWW-Authenticate, Set-Cookie and the
security headers are kept.
The response is sent with Cache-Control: no-store, because an error is never worth storing.
A handler that set Content-Type: text/html or a public Cache-Control and then failed used
to send its JSON error, reflected input included, under those headers. A custom ErrorRenderer
runs after this and may set any header it wants, including a Cache-Control of its own, with
one exception: a response that carries the session cookie, which
Session.Save can put on an error, is kept private whatever the renderer sets.
Replacing the envelope
For RFC 9457 problem details you need no renderer of your own: AppOptions.ProblemDetails
renders every error as a muzak.Problem, sent as application/problem+json, with the same
codes, details, translations and opacity as the default envelope, and describes errors that
way in the OpenAPI document. See Problem Details.
For any other shape, AppOptions.ErrorRenderer replaces it entirely. The renderer below
builds a problem by hand, to show what one is given and what it returns.
func renderError(ctx *muzak.Context, err error) (int, any) {
var verr *muzak.ValidationError
if errors.As(err, &verr) {
return http.StatusUnprocessableEntity, problemDetails{
Type: "https://example.com/probs/validation",
Title: "Your request is not valid.",
Status: http.StatusUnprocessableEntity,
Instance: ctx.RequestID(),
Errors: verr.Details,
}
}
var coder muzak.StatusCoder
if errors.As(err, &coder) {
status := coder.HTTPStatus()
return status, problemDetails{
Type: "about:blank",
Title: err.Error(),
Status: status,
Instance: ctx.RequestID(),
}
}
// Anything unrecognised stays opaque, exactly as the default renderer does.
return http.StatusInternalServerError, problemDetails{
Type: "about:blank",
Title: "An internal error occurred.",
Status: http.StatusInternalServerError,
Instance: ctx.RequestID(),
}
}
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
ErrorRenderer: renderError,
})
A renderer receives the request Context, so the route, the request identifier and the
headers are all available. Returning a nil body writes the status with no content. A status
outside 100 to 599 is written as 500, with or without a body.
A renderer must not leak internal state. The error it receives may be anything a handler
returned, including a wrapped database or filesystem error, so classify the errors you
recognise and fall back to an opaque response for the rest, exactly as
muzak.DefaultErrorRenderer does. That function is exported, so a renderer that only
wants to special-case one thing can delegate the rest to it.
Correlating a response with the log
Every response carries X-Request-Id, and every error body repeats it as request_id.
The same value appears in the access log line and in every log record the request
produced.
14:32:10.114 WARN [Request] POST /items/ status=409 duration=412µs bytes=181 request_id=0611f4b2
An error carrying a cause through Wrap, and every error that does not describe itself,
also produces a record of its own at error level with the real reason in it. The reason
stays in the log; only the envelope above reaches the client. A deliberate 4xx that
carries no cause is fully described by its response, so it gets no extra line.
Identifiers are generated per request and are not taken from the client by default, because an attacker-controlled identifier is an attacker-controlled log field. See Logging for how to change that.
Where to go next
Middleware covers the chain an error travels
back out through, and Validation covers the
rules behind a 422.