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.

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 any error implementing StatusCoder.

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

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 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"
}

Two 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. And the message is err.Error(), transmitted verbatim, so an error type that implements StatusCoder has declared its Error() text safe to disclose. Keep file paths, driver messages and query fragments out of it.

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.

Failures the framework reports on its own

StatusWhen
404No route matched the path, and no frontend claimed it
405The path exists but not for that method. The response carries Allow
413The request body exceeded the route's limit, refused while it was being read
415A body arrived under a media type the route cannot decode
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, or a rate limit storage could not answer
500A panic, or any error that does not describe itself

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.

Replacing the envelope

AppOptions.ErrorRenderer replaces the shape entirely.

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 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 · sustained by the people who ship on it.Built on net/http, and nothing else.