Skip to content

Error Handling

Use WithError to declare that a route may return a specific error type at a given HTTP status code. The error type must implement the error interface — its struct fields are reflected into the OpenAPI schema automatically.

type ConflictError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (e *ConflictError) Error() string { return e.Message }
shiftapi.Handle(api, "POST /users", createUser,
shiftapi.WithError[*ConflictError](http.StatusConflict),
shiftapi.WithError[*NotFoundError](http.StatusNotFound),
)
func createUser(r *http.Request, in *CreateUser) (*User, error) {
if exists {
return nil, &ConflictError{
Code: "EMAIL_TAKEN",
Message: "a user with that email already exists",
}
}
return user, nil
}

This returns:

HTTP/1.1 409 Conflict
{"code": "EMAIL_TAKEN", "message": "a user with that email already exists"}

At runtime, ShiftAPI checks if the returned error matches a registered type using errors.As. Wrapped errors work automatically:

return nil, fmt.Errorf("db lookup failed: %w", &ConflictError{Code: "EMAIL_TAKEN", Message: "conflict"})

A single route can declare multiple error types:

shiftapi.Handle(api, "POST /users", createUser,
shiftapi.WithError[*ConflictError](http.StatusConflict),
shiftapi.WithError[*NotFoundError](http.StatusNotFound),
shiftapi.WithError[*ForbiddenError](http.StatusForbidden),
)

Each declared error type appears as a separate response in the generated OpenAPI spec with its own schema.

WithError is an Option that works at all three levels:

// API level — applies to all routes
api := shiftapi.New(
shiftapi.WithError[*AuthError](http.StatusUnauthorized),
)
// Group level — applies to all routes in the group
v1 := api.Group("/api/v1",
shiftapi.WithError[*RateLimitError](http.StatusTooManyRequests),
)
// Route level — applies to this route only
shiftapi.Handle(v1, "GET /users/{id}", getUser,
shiftapi.WithError[*NotFoundError](http.StatusNotFound),
)

Error types merge across all three levels. In the example above, GET /api/v1/users/{id} has AuthError (from API), RateLimitError (from group), and NotFoundError (from route).

When the framework cannot parse the request (malformed JSON, invalid query parameter types, invalid header values, invalid form data), it returns a 400 Bad Request. By default the response body is {"message": "bad request"}.

Use WithBadRequestError to customize the 400 response body. The function receives the parse error so you can decide what to expose to the client:

api := shiftapi.New(
shiftapi.WithBadRequestError(func(err error) *MyBadRequest {
return &MyBadRequest{Code: "BAD_REQUEST", Message: err.Error()}
}),
)

The return type determines the BadRequestError schema in the OpenAPI spec.

When request validation fails, ShiftAPI automatically returns a 422 Unprocessable Entity with per-field error details:

HTTP/1.1 422 Unprocessable Entity
{
"message": "validation failed",
"errors": [
{ "field": "Email", "message": "must be a valid email address" }
]
}

If a handler returns an error that doesn’t match ValidationError or any type registered with WithError, ShiftAPI returns a 500 Internal Server Error with a generic message. The original error is not exposed to the client.

Use WithInternalServerError to customize the 500 response body. The function receives the unhandled error so you can log it or extract details:

api := shiftapi.New(
shiftapi.WithInternalServerError(func(err error) *MyServerError {
log.Error("unhandled", "err", err)
return &MyServerError{Code: "INTERNAL_ERROR", Message: "internal server error"}
}),
)

The return type determines the InternalServerError schema in the OpenAPI spec.

Every route in the generated OpenAPI spec includes:

  • 400BadRequestError schema (default: message only, customizable via WithBadRequestError)
  • 422ValidationError schema (message + errors array)
  • 500InternalServerError schema (default: message only, customizable via WithInternalServerError)
  • Any additional status codes declared via WithError, each with the custom error type’s schema

These schemas are registered as reusable components in #/components/schemas/ and referenced via $ref.