zoobzio December 15, 2025 Edit this page

Error Handling Guide

Rocco uses typed sentinel errors for consistent, documented error responses. This guide covers error patterns, custom errors, and best practices.

Error Response Format

All errors return a consistent JSON structure:

{
  "code": "NOT_FOUND",
  "message": "user not found",
  "details": {
    "resource": "user"
  }
}
FieldDescription
codeMachine-readable error code (e.g., NOT_FOUND)
messageHuman-readable message
detailsOptional structured details (varies by error type)

Built-in Errors

Rocco provides typed errors for common HTTP status codes:

ErrorStatusCodeDetails Type
ErrBadRequest400BAD_REQUESTBadRequestDetails
ErrUnauthorized401UNAUTHORIZEDUnauthorizedDetails
ErrForbidden403FORBIDDENForbiddenDetails
ErrNotFound404NOT_FOUNDNotFoundDetails
ErrConflict409CONFLICTConflictDetails
ErrPayloadTooLarge413PAYLOAD_TOO_LARGEPayloadTooLargeDetails
ErrUnprocessableEntity422UNPROCESSABLE_ENTITYUnprocessableEntityDetails
ErrValidationFailed422VALIDATION_FAILEDValidationDetails
ErrTooManyRequests429TOO_MANY_REQUESTSTooManyRequestsDetails
ErrInternalServer500INTERNAL_SERVER_ERRORInternalServerDetails
ErrNotImplemented501NOT_IMPLEMENTEDNotImplementedDetails
ErrServiceUnavailable503SERVICE_UNAVAILABLEServiceUnavailableDetails

Using Errors

Simple Error Return

func(req *rocco.Request[rocco.NoBody]) (User, error) {
    user, err := db.FindUser(req.Params.Path["id"])
    if err != nil {
        return User{}, rocco.ErrNotFound
    }
    return user, nil
}

With Custom Message

return User{}, rocco.ErrNotFound.WithMessage("user not found")

With Typed Details

return User{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
    Resource: "user",
})

Response:

{
  "code": "NOT_FOUND",
  "message": "not found",
  "details": {
    "resource": "user"
  }
}

Combining Message and Details

return User{}, rocco.ErrNotFound.
    WithMessage("user not found").
    WithDetails(rocco.NotFoundDetails{Resource: "user"})

Error Declaration

All sentinel errors must be declared with WithErrors():

handler := rocco.NewHandler[rocco.NoBody, User](
    "get-user",
    "GET",
    "/users/{id}",
    getUser,
).WithErrors(rocco.ErrNotFound, rocco.ErrForbidden)

Why Declaration Matters

  1. OpenAPI Generation: Declared errors appear in the API spec with proper schemas
  2. Runtime Safety: Undeclared errors return 500 to prevent information leakage
  3. Documentation: Consumers know which errors to expect

Undeclared Error Behavior

If a handler returns an undeclared sentinel error:

  1. Rocco logs a warning with the error details
  2. Returns 500 Internal Server Error to the client
  3. The original error code is hidden

This catches programming errors where handlers return errors they didn't declare.

Custom Errors

Define domain-specific errors with typed details:

Step 1: Define Details Type

type InsufficientFundsDetails struct {
    Required  float64 `json:"required" description:"Amount required"`
    Available float64 `json:"available" description:"Amount available"`
    Currency  string  `json:"currency" description:"Currency code"`
}

Step 2: Create Error

var ErrInsufficientFunds = rocco.NewError[InsufficientFundsDetails](
    "INSUFFICIENT_FUNDS",  // Code
    402,                   // Status
    "insufficient funds",  // Default message
)

Step 3: Use in Handler

handler := rocco.NewHandler[TransferInput, TransferResult](
    "transfer-funds",
    "POST",
    "/transfers",
    func(req *rocco.Request[TransferInput]) (TransferResult, error) {
        balance := getBalance(req.Body.FromAccount)
        if balance < req.Body.Amount {
            return TransferResult{}, ErrInsufficientFunds.WithDetails(InsufficientFundsDetails{
                Required:  req.Body.Amount,
                Available: balance,
                Currency:  "USD",
            })
        }
        return executeTransfer(req.Body)
    },
).WithErrors(ErrInsufficientFunds, rocco.ErrNotFound)

Response:

{
  "code": "INSUFFICIENT_FUNDS",
  "message": "insufficient funds",
  "details": {
    "required": 100.00,
    "available": 50.00,
    "currency": "USD"
  }
}

Error Wrapping

Wrap underlying errors for debugging:

return User{}, rocco.ErrInternalServer.WithCause(err)

The cause is logged but not exposed to clients.

Validation Errors

Validation failures automatically return structured errors:

{
  "code": "VALIDATION_FAILED",
  "message": "validation failed",
  "details": {
    "fields": [
      {"field": "Email", "tag": "email", "value": "not-an-email"},
      {"field": "Name", "tag": "min", "value": "A"}
    ]
  }
}

These are generated automatically from validate struct tags.

Error Patterns

Resource Not Found

user, err := db.FindUser(id)
if errors.Is(err, sql.ErrNoRows) {
    return User{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
        Resource: "user",
    })
}
if err != nil {
    return User{}, rocco.ErrInternalServer.WithCause(err)
}

Conflict Detection

_, err := db.CreateUser(input)
if isUniqueViolation(err) {
    return User{}, rocco.ErrConflict.
        WithMessage("email already registered").
        WithDetails(rocco.ConflictDetails{
            Reason: "a user with this email already exists",
        })
}

Authorization Errors

if !req.Identity.HasScope("users:write") {
    return User{}, rocco.ErrForbidden.WithMessage("requires users:write scope")
}

Multiple Error Conditions

func(req *rocco.Request[UpdateUserInput]) (User, error) {
    user, err := db.FindUser(req.Params.Path["id"])
    if errors.Is(err, sql.ErrNoRows) {
        return User{}, rocco.ErrNotFound
    }
    if err != nil {
        return User{}, rocco.ErrInternalServer.WithCause(err)
    }

    if user.TenantID != req.Identity.TenantID() {
        return User{}, rocco.ErrForbidden.WithMessage("cannot access other tenant's users")
    }

    updated, err := db.UpdateUser(user.ID, req.Body)
    if isUniqueViolation(err) {
        return User{}, rocco.ErrConflict.
            WithMessage("email already in use").
            WithDetails(rocco.ConflictDetails{
                Reason: "this email is already registered to another user",
            })
    }

    return updated, nil
}

Best Practices

1. Declare All Errors

Always declare every error a handler may return:

handler.WithErrors(
    rocco.ErrNotFound,
    rocco.ErrConflict,
    rocco.ErrForbidden,
)

2. Use Specific Error Types

// GOOD - specific error
return User{}, rocco.ErrNotFound

// AVOID - generic error
return User{}, errors.New("not found")

3. Add Context with Details

// GOOD - useful context
return User{}, rocco.ErrNotFound.WithDetails(rocco.NotFoundDetails{
    Resource: "user",
})

// LESS USEFUL - no context
return User{}, rocco.ErrNotFound

4. Hide Internal Errors

// GOOD - hide internal details
if err != nil {
    return User{}, rocco.ErrInternalServer.WithCause(err)
}

// BAD - exposes internal error
return User{}, err

5. Domain-Specific Errors

Create custom errors for domain concepts:

var (
    ErrOrderCancelled = rocco.NewError[OrderCancelledDetails](
        "ORDER_CANCELLED", 400, "order already cancelled")
    ErrInventoryLow = rocco.NewError[InventoryDetails](
        "INVENTORY_LOW", 409, "insufficient inventory")
)

See Also