Skip to content

Handle errors

Handlers return error. Return a gas.Error and core renders it at the right status in a stable envelope, so applications do not each invent their own error struct.

users/service.go
// Handlers return error. Return a gas.Error and core renders it at the right
// status in a stable JSON shape, so applications do not each invent their own.
func show(ctx gas.Context, db gas.DatabaseProvider) error {
user, err := find(ctx, db, ctx.Param("id"))
if errors.Is(err, sql.ErrNoRows) {
// WithCause keeps the real error reachable through errors.Is and puts
// it in the log. It is never serialized into the response.
return gas.NotFound("user not found").WithCause(err)
}
if err != nil {
return err // renders as a generic 500; the real error goes to the log
}
return ctx.JSON(http.StatusOK, user)
}
{"error":{"code":"not_found","message":"user not found"}}
Constructor Status Code
gas.BadRequest(msg) 400 bad_request
gas.Unauthorized(msg) 401 unauthorized
gas.Forbidden(msg) 403 forbidden
gas.NotFound(msg) 404 not_found
gas.Conflict(msg) 409 conflict
gas.Unprocessable(msg) 422 validation_failed
gas.TooManyRequests(msg) 429 rate_limited
gas.Internal(msg) 500 internal_error
gas.ServiceUnavailable(msg) 503 service_unavailable
gas.NewError(status, code, msg) custom custom
// Binding produces the same shape for free. A malformed body is a 400, and a
// struct that fails validation is a 422 with per-field detail, named by the
// JSON tag the client actually sent.
type CreateUser struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required"`
}
func create(ctx gas.Context) error {
var req CreateUser
if err := ctx.BindJSON(&req); err != nil {
return err
}
return ctx.JSON(http.StatusCreated, req)
}

Returning the error straight out of BindJSON gives a 400 (invalid_json) when the body is malformed, or a 422 with per-field detail when it parses but fails validation. Fields are named by the JSON tag the client actually sent, not the Go field name:

{"error":{"code":"validation_failed","message":"request validation failed",
"fields":[{"field":"email","rule":"email","message":"must be a valid email address"}]}}

BindForm does the same for form bodies, using the form struct tag.

// Build a richer error when the client needs more than a message.
func conflict(email string) error {
return gas.Conflict("email already registered").
WithField("email", "unique", "that address is already in use").
WithDetail("suggestion", email+"+1@example.com")
}
// AsError classifies an error coming back from a service call.
func classify(err error) int {
if gasErr, ok := gas.AsError(err); ok {
return gasErr.Status
}
return http.StatusInternalServerError
}

Clients that do not explicitly prefer text/html get the JSON envelope. Clients that prefer HTML without also accepting JSON get a plain-text body instead, so server-rendered apps stay readable with no configuration.

To render real HTML error pages, replace the handler:

// A custom handler owns the whole response. gas.WriteError renders the unified
// shape from any http.Handler, without logging or touching the request scope,
// so it is safe in middleware that runs before the scope exists.
func htmlOrJSON() gas.AppOption {
return gas.WithErrorHandler(func(ctx gas.Context, err error) {
if gas.WantsJSON(ctx.Request()) {
_ = ctx.WriteError(err)
return
}
_ = ctx.HTML(http.StatusInternalServerError, "<h1>Something went wrong</h1>")
})
}

A custom handler owns the entire response, so nothing is written unless you write it. gas.WriteError(w, r, err) renders the same envelope from any http.Handler, without logging and without touching the request scope, which makes it safe in middleware that runs before the scope exists, such as a CSRF deny handler. gas.WantsJSON(r) exposes the same negotiation.

DI-aware handlers recover automatically. The stack trace goes to stderr, the error is logged through the request-scoped logger when one is available, and the panic is routed through the error handler as gas: handler panic: <value>. http.ErrAbortHandler is re-panicked so net/http can tear the connection down the way it expects.