Skip to content

Error Model

Every Plumego handler returns errors using the same two functions: contract.WriteError and contract.NewErrorBuilder. This single path means every error response has a consistent JSON shape that clients can parse reliably.

{
"error": {
"type": "required_field_missing",
"code": "REQUIRED",
"message": "name is required",
"category": "validation_error",
"details": { "field": "name" }
},
"request_id": "0jx9f3kp2q"
}

request_id is injected automatically when middleware/requestid has run. You never set it manually.

Use NewErrorBuilder — never construct APIError as a struct literal:

import "github.com/spcent/plumego/contract"
err := contract.NewErrorBuilder().
Type(contract.TypeRequired). // sets type, code, category, HTTP status
Detail("field", "name"). // optional: adds to the details map
Message("name is required"). // overrides the default human-readable text
Build()
contract.WriteError(w, r, err)

Calling .Type() is the only required step. It populates type, code, category, and the HTTP status code automatically. You only override Message and Detail when the default text is insufficient.

ConstantHTTPcodecategory
TypeValidation400VALIDATION_ERRORvalidation_error
TypeRequired400REQUIRED_FIELD_MISSINGvalidation_error
TypeInvalidFormat400INVALID_FORMATvalidation_error
TypeOutOfRange400VALUE_OUT_OF_RANGEvalidation_error
TypeDuplicate400DUPLICATE_VALUEvalidation_error
TypeUnauthorized401UNAUTHORIZEDauth_error
TypeInvalidToken401INVALID_TOKENauth_error
TypeExpiredToken401EXPIRED_TOKENauth_error
TypeForbidden403FORBIDDENauth_error
TypeNotFound404RESOURCE_NOT_FOUNDclient_error
TypeConflict409RESOURCE_CONFLICTclient_error
TypeAlreadyExists409RESOURCE_ALREADY_EXISTSclient_error
TypeGone410RESOURCE_GONEclient_error
TypeRateLimited429RATE_LIMITEDrate_limit_error
TypeInternal500INTERNAL_ERRORserver_error
TypeUnavailable503SERVICE_UNAVAILABLEserver_error
TypeTimeout408TIMEOUTtimeout_error
TypeMethodNotAllowed405METHOD_NOT_ALLOWEDclient_error
TypeNotImplemented501NOT_IMPLEMENTEDserver_error
TypeBadGateway502BAD_GATEWAYserver_error
TypeGatewayTimeout504GATEWAY_TIMEOUTtimeout_error
TypeMaintenance503MAINTENANCE_MODEserver_error

The category field groups errors for observability and alerting:

ValueMeaning
client_error4xx — bad client input
server_error5xx — infrastructure or server logic failure
validation_errorInput validation failure (subset of client_error)
auth_errorAuthentication or authorization failure
rate_limit_errorRate limit exceeded
timeout_errorTimeout
func (h ItemHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeValidation).
Message(err.Error()).
Build())
return
}
if req.Name == "" {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeRequired).
Detail("field", "name").
Build())
return
}
item, err := h.svc.Create(r.Context(), req)
if errors.Is(err, ErrAlreadyExists) {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeAlreadyExists).
Message("item with this name already exists").
Build())
return
}
if err != nil {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeInternal).
Build())
return
}
contract.WriteResponse(w, r, http.StatusCreated, item, nil)
}

When you need to return multiple field errors at once, use the Details map to carry them:

errs := map[string]string{}
if req.Name == "" { errs["name"] = "required" }
if req.Email == "" { errs["email"] = "required" }
if len(errs) > 0 {
b := contract.NewErrorBuilder().
Type(contract.TypeValidation).
Message("request validation failed")
for field, msg := range errs {
b = b.Detail(field, msg)
}
contract.WriteError(w, r, b.Build())
return
}