Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

Every error returned by the pipeline is delivered to the client as the same JSON envelope. This page describes that envelope, the sentinel errors the framework recognises, and how to produce errors from middleware.

The error envelope

A failing request writes:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "field \"email\" is required",
    "details": { /* optional, per-error */ }
  }
}

with the HTTP status code from the underlying failure. The code is a short machine-readable identifier; message is a human-readable summary; details is optional and may carry per-field errors or other structured context.

A successful request uses the {"data": ...} envelope instead; the two are mutually exclusive.

Built-in error responses

The default pipeline produces the following errors without any user code:

StatusCodeSource
400INVALID_JSONmalformed JSON body
400EMPTY_BODYempty body on POST / PATCH
400BODY_READ_ERRORbody exceeded the 4 MB read limit
400INVALID_QUERYunknown filter/sort field, malformed ?include, etc.
400MULTIPART_ERRORmalformed multipart/form-data
404NOT_FOUNDrecord does not exist (or is soft-deleted)
409CONFLICTunique or check constraint violated
422VALIDATION_FAILEDone or more mfx: tag rules failed
500DATABASE_ERRORunclassified adapter error
500TX_BEGIN_ERROR / TX_COMMIT_ERRORtransaction lifecycle failure
501NO_STORAGEfile endpoint hit with no FileStorage configured
504TIMEOUTrequest context deadline exceeded

A panic anywhere in the pipeline is caught and reported as 500 PANIC by the framework’s recoverer.

Aborting from middleware

The standard way to produce an error from middleware is ctx.Abort:

func ctx.Abort(statusCode int, code, message string)

It populates ctx.Response with an error envelope. The caller must then return nil (or an error) without calling next():

if header == "" {
    ctx.Abort(http.StatusUnauthorized, "UNAUTHORIZED", "missing token")
    return nil
}

Subsequent steps are skipped; the Response step writes the prepared envelope. Calling next() after Abort allows downstream steps to overwrite the response — usually not what you want.

Returning structured details

For per-field errors and similar payloads, set ctx.Response directly:

ctx.Response = &maniflex.APIResponse{
    StatusCode: http.StatusUnprocessableEntity,
    Error: &maniflex.APIError{
        Code:    "VALIDATION_FAILED",
        Message: "one or more fields failed validation",
        Details: []map[string]string{
            {"field": "email",    "message": "must be a valid email"},
            {"field": "password", "message": "must be at least 8 characters"},
        },
    },
}
return nil

This is the shape used by the default Validate step.

Sentinel errors from the adapter

Adapter methods return errors that the DB step maps to HTTP responses. The two that user code most often interacts with are exported as sentinels.

maniflex.ErrNotFound

var ErrNotFound = errors.New("record not found")

Returned by FindByID, Update, and Delete when the row does not exist (or is soft-deleted). Detect it with errors.Is:

row, err := ctx.GetModel("Invoice").Read(id)
if errors.Is(err, maniflex.ErrNotFound) {
    ctx.Abort(http.StatusNotFound, "INVOICE_NOT_FOUND",
        fmt.Sprintf("invoice %s does not exist", id))
    return nil
}

The default DB step does this conversion automatically; you only need it when you are calling the adapter yourself from a Service middleware.

*maniflex.ErrConstraint

type ErrConstraint struct {
    Table   string
    Column  string  // may be empty when the driver does not expose it
    Detail  string  // raw driver message
}

Returned by Create and Update on unique or check constraint violations. Both SQLite and Postgres errors are normalised into this type, so middleware need not inspect driver-specific codes.

row, err := ctx.GetModel("User").Create(data)
var ec *maniflex.ErrConstraint
if errors.As(err, &ec) {
    ctx.Abort(http.StatusConflict, "DUPLICATE",
        fmt.Sprintf("%s already exists", ec.Column))
    return nil
}

Errors and transactions

When a request runs inside a transaction (see Transactions) and any step returns an error or sets ctx.Response to status >= 400, the transaction is rolled back before the response is written. The client sees the error envelope; the database sees no change.

Logging errors

ctx.Logger() returns a *slog.Logger pre-seeded with request_id and trace_id, so a single log line correlates with the request that produced it:

ctx.Logger().Error("payment provider rejected charge",
    slog.String("provider", "stripe"),
    slog.String("error_code", resp.Code),
)
ctx.Abort(http.StatusBadGateway, "PAYMENT_DECLINED", resp.Message)
return nil

Log first, then abort — the log line is what you’ll need when debugging.

Next