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_ERROR",
    "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_ERRORan I/O failure while reading the request body
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
413BODY_TOO_LARGErequest body exceeded the 4 MB read limit
422VALIDATION_ERRORone or more mfx: tag rules failed
500DB_ERRORunclassified adapter error
500TX_BEGIN_ERROR / TX_COMMIT_ERRORtransaction lifecycle failure
499(no body)the client disconnected before the response was written
501NO_STORAGEfile endpoint hit with no FileStorage configured
504TIMEOUTConfig.QueryTimeout (or another server-side deadline) expired

A panic anywhere in the pipeline is caught and reported as 500 PANIC by the framework’s recoverer — with one deliberate exception. A handler that panics with http.ErrAbortHandler is abandoning its response on purpose (this is what httputil.ReverseProxy does when an upstream dies mid-stream), so the recoverer passes it on and net/http closes the connection silently. It is not logged as a panic, and no error envelope is appended to whatever was already written.

499 is nginx’s non-standard “Client Closed Request” (maniflex.StatusClientClosedRequest). Nothing is written to the client — the connection is already gone — but the status is what your access log, metrics, and any After middleware reading the response status will see, so a caller who hangs up is not counted as a server timeout. Only a genuine server-side deadline produces 504 TIMEOUT. The disconnect itself is logged at DEBUG, not as an error.

Aborting from middleware

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

ctx.Abort(statusCode, code, message)

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.

For a 5xx status, message is a private diagnostic: Maniflex logs it once through Config.Logger, correlated by request_id, but sends only generic status text such as internal server error or bad gateway. The HTTP status and stable error code remain public. Any details on a directly assigned 5xx APIResponse are also removed at the wire boundary. Validated 4xx messages and details are unchanged. Every routed response echoes the correlation value in X-Request-Id.

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_ERROR",
        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 {
    Kind    ConstraintKind  // unique, foreign_key, or not_null
    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
}

Every exported sentinel

ErrNotFound and ErrConstraint above are the two you match in a handler. The rest are listed here because they are part of the compatibility contract: a sentinel keeps its identity for all of v1, so errors.Is against any of them goes on working. Match on these values, never on message text — the text is explicitly not covered (see Stability & Compatibility).

Core module, github.com/xaleel/maniflex:

SentinelReturned when
ErrNotFoundthe row does not exist, or is soft-deleted
*ErrConstrainta unique or check constraint was violated — a type, not a value; use errors.As
ErrNoAdapterBeginTx is called with no database adapter configured
ErrRawNotSupportedInTxRawQuery/RawExec run inside a transaction whose Tx cannot execute raw SQL. Refused rather than silently run outside the transaction
ErrIncrementOutOfBoundsIncrement would take a column past its mfx:"min:"/"max:" bound. Distinct from ErrNotFound: only this one may succeed on a retry
ErrIncrementNotSupportedthe adapter does not implement Incrementer
ErrFileNotFoundFileStorage.Retrieve is given a key that does not exist
ErrPresignUnsupportedthe storage backend cannot mint a presigned upload; the upload-url route answers 501
ErrAlreadyStarteda start method is called while the server already has an active startup owner
ErrStoppeda start method is called after the server stopped or failed. A server is not restartable — build a new one
ErrRegistrationCloseda route or spec contributor is registered after Start/Handler sealed the server

Subpackages:

SentinelPackageReturned when
ErrBusClosedevents/inprocPublish after Close — the event was never accepted
ErrQueueFullevents/inproca matching subscription’s queue is full. The bus is behind; the caller still holds the event
ErrDrainIncompleteevents/inprocClose gave up with deliveries still running
ErrUnauthorizedrealtimean Authenticator rejected a connection — a type, carrying Reason
ErrResponseTooLargepkg/integrationan upstream response exceeded MaxResponseBytes
ErrCrossOriginRedirectpkg/integrationthe redirect policy refused to forward the request, and its headers, to another origin
ErrHTTPStatuspkg/integrationthe upstream answered non-2xx — a type, carrying the status and decoded body
ErrWebhookReplaypkg/integrationa signed webhook was already accepted; the receiver maps it to 409
ErrImbalancedpkg/ledgerdebits do not equal credits for some currency in the entry
ErrNoLinespkg/ledgerPost was called with fewer than two lines
ErrCurrencyMismatchpkg/moneyAdd/Sub were given amounts in different currencies

TestErrorsDocListsEveryExportedSentinel fails when an exported Err* identifier is missing from this page, so a new one cannot ship undocumented.

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_PROVIDER_ERROR", resp.Message)
return nil

Abort logs the supplied 5xx message automatically. The explicit log is still useful when you have structured provider fields that should not be flattened into the diagnostic string. Neither resp.Message nor those fields reach the client.

Next