You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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.

MethodWhat 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"
}
ConstructorStatusCode
BadRequest(message)400bad_request
Unauthorized(message)401unauthorized
PaymentRequired(message)402payment_required
Forbidden(message)403forbidden
NotFound(message)404not_found
MethodNotAllowed(message)405method_not_allowed
NotAcceptable(message)406not_acceptable
RequestTimeout(message)408request_timeout
Conflict(message)409conflict
Gone(message)410gone
PreconditionFailed(message)412precondition_failed
PayloadTooLarge(message)413payload_too_large
UnsupportedMediaType(message)415unsupported_media_type
UnprocessableEntity(message)422validation_error
TooManyRequests(message)429too_many_requests
InternalServerError(message)500internal_error
NotImplemented(message)501not_implemented
BadGateway(message)502bad_gateway
ServiceUnavailable(message)503service_unavailable
GatewayTimeout(message)504gateway_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.

ConstantValue
CodeBadRequestbad_request
CodeUnauthorizedunauthorized
CodePaymentRequiredpayment_required
CodeForbiddenforbidden
CodeNotFoundnot_found
CodeMethodNotAllowedmethod_not_allowed
CodeNotAcceptablenot_acceptable
CodeRequestTimeoutrequest_timeout
CodeConflictconflict
CodeGonegone
CodePreconditionFailedprecondition_failed
CodePayloadTooLargepayload_too_large
CodeUnsupportedMediaTypeunsupported_media_type
CodeValidationErrorvalidation_error
CodeTooManyRequeststoo_many_requests
CodeNotImplementednot_implemented
CodeBadGatewaybad_gateway
CodeServiceUnavailableservice_unavailable
CodeGatewayTimeoutgateway_timeout
CodeInternalErrorinternal_error
CodeClientErrorclient_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

StatusWhen
401A 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
403The 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)
404No route matched the path, and no frontend claimed it
405The 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
413The 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
415A body arrived under a media type the route cannot decode, or with no Content-Type at all
421AppOptions.AllowedHosts does not list the request's host, with the code misdirected_request (muzak.CodeMisdirectedRequest)
422Binding or validation failed
429A rate limit was exceeded. The response carries Retry-After and the RateLimit headers
503The 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)
500A 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.