Skip to content

Middleware and errors

The default middleware order is deliberate: request ID, access logging, panic recovery, error mapping, security headers, body limits, CORS, rate limiting, then your routes. A panic becomes a stable 500 internal_error response and a correlated log entry.

Every failure renders the same envelope ({"error": {code, message, details, request_id}}) with a machine-readable code that will not change between releases:

Code Status Emitted when
invalid_json 400 Malformed body, unknown fields, or trailing JSON. The payload is never echoed.
invalid_query 400 Query parameters fail binding, or the query builder rejects a filter/sort.
invalid_path 400 Path parameters fail binding.
validation_failed 422 Semantic validation; details.fields carries per-field failures.
body_too_large 413 The body exceeds MAX_BODY_BYTES.
rate_limited 429 The endpoint’s rate-limit class is exhausted.
missing_token / invalid_token 401 Protected route without/with a bad bearer token.
insufficient_scope 403 API token lacks one or more required abilities.
token_not_found 404 API token does not exist or is not owned by the authenticated user.
invalid_credentials / invalid_refresh_token / email_taken 401 / 401 / 409 Auth flows.
csrf_token_mismatch 403 UI-mode form posts without a valid CSRF token.
oauth_provider_unavailable / oauth_state_invalid / oauth_identity_unverified / oauth_authorization_denied / oauth_callback_failed browser redirect OAuth callbacks set a generic flash and redirect to OAUTH_FAILURE_REDIRECT; provider details are never exposed.
forbidden 403 An authorization policy denied the action. The internal deny reason goes to logs, never to the body.
<resource>_not_found 404 Generated handlers, e.g. ticket_not_found.
not_found / method_not_allowed 404 / 405 Router-level fallbacks.
internal_error 500 Anything unexpected; the cause goes to logs, never to clients.

Options.ErrorMapper turns domain errors into public responses in one place. The mapper receives every error passed to httpx.Handle; return nil-safe *httpx.Error values and fall back to the default:

app, err := runtime.New(runtime.Options{
ErrorMapper: func(err error, c *gin.Context) *httpx.Error {
switch {
case errors.Is(err, domain.ErrQuotaExceeded):
return httpx.NewError(http.StatusPaymentRequired, "quota_exceeded",
"The workspace quota is exhausted.")
case errors.Is(err, domain.ErrTicketNotFound):
return httpx.NewError(http.StatusNotFound, "ticket_not_found", "Ticket not found.")
default:
return httpx.DefaultMapper(err, c)
}
},
})

The default mapper already understands *httpx.Error values (pass-through) and *validation.Errors (rendered as 422 validation_failed); everything else becomes 500 internal_error with the cause preserved for logs only.

In development, logs include the operation and cause. Production responses remain stable and concise in every environment. Configure the logger through Options.Logger, and correlate any failure with its log entry via the request_id field.