Skip to main content

Why Error Handling Matters

When building APIs, errors are inevitable. Users will request items that don’t exist, provide invalid data, or lack permissions. How you handle these errors determines whether your API is frustrating or pleasant to use. Contract provides a portable error system that ensures your errors work correctly across REST, JSON-RPC, MCP, and other protocols - without you having to think about each one separately.

The Problem with Simple Errors

Consider this code in a todo service:
package todo

func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    todo := s.findTodo(in.ID)
    if todo == nil {
        return nil, errors.New("not found")  // Simple error
    }
    return todo, nil
}
The problem? Different protocols expect errors in different formats:
ProtocolExpected Format
RESTHTTP 404 status with error body
JSON-RPC{"error": {"code": -32601, "message": "..."}}
MCP{"content": [...], "isError": true}
With a simple errors.New(), Contract can’t know which HTTP status to use, so it defaults to 500 Internal Server Error - even though “not found” should clearly be 404.

The Solution: Contract Errors

Contract provides error types that know how to translate themselves to each protocol:
package todo

import contract "github.com/go-mizu/mizu/contract/v2"

func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    todo := s.findTodo(in.ID)
    if todo == nil {
        return nil, contract.ErrNotFound("todo not found")  // Contract error
    }
    return todo, nil
}
This single line automatically becomes the right format for each protocol:
ProtocolResponse
RESTHTTP 404 with {"error": "todo not found"}
JSON-RPC{"error": {"code": -32601, "message": "todo not found"}}
MCP{"content": [{"type":"text","text":"todo not found"}], "isError": true}
You write one error, and it works everywhere.

Creating Errors

Contract provides convenient functions for common error types:
import contract "github.com/go-mizu/mizu/contract/v2"

// Resource not found (HTTP 404)
contract.ErrNotFound("user not found")

// Invalid input data (HTTP 400)
contract.ErrInvalidArgument("email address is invalid")

// User not logged in (HTTP 401)
contract.ErrUnauthenticated("please log in")

// User lacks permission (HTTP 403)
contract.ErrPermissionDenied("admin access required")

// Something went wrong on the server (HTTP 500)
contract.ErrInternal("database unavailable")

// Resource already exists (HTTP 409)
contract.ErrAlreadyExists("username already taken")

// Rate limiting (HTTP 429)
contract.ErrResourceExhausted("too many requests")

// Feature not implemented (HTTP 501)
contract.ErrUnimplemented("export feature coming soon")

// Service temporarily down (HTTP 503)
contract.ErrUnavailable("maintenance in progress")

Method 2: NewError with Code

For more control, use NewError with an explicit error code:
err := contract.NewError(contract.ErrCodeNotFound, "user not found")

Method 3: Errorf with Formatting

Use Errorf when you need to include variables in the message:
err := contract.Errorf(contract.ErrCodeNotFound, "user %s not found", userID)
err := contract.Errorf(contract.ErrCodeInvalidArgument, "field %s: %s", fieldName, reason)

Choosing the Right Error Code

User’s Fault (4xx HTTP Status)

Use these when the client made a mistake or is requesting something they can’t have:
// Resource doesn't exist
contract.ErrNotFound("order #12345 not found")

// Bad input data - validation failed
contract.ErrInvalidArgument("email must be a valid email address")

// Need to log in first
contract.ErrUnauthenticated("session expired, please log in again")

// Logged in but can't do this action
contract.ErrPermissionDenied("only admins can delete users")

// Tried to create something that exists
contract.ErrAlreadyExists("account with this email already exists")

// Too many requests - rate limited
contract.ErrResourceExhausted("rate limit exceeded, try again in 60 seconds")

// Wrong state for operation
contract.ErrFailedPrecondition("order already shipped, cannot cancel")

Your Fault (5xx HTTP Status)

Use these when something went wrong on your end:
// Unexpected error - bugs, panics caught
contract.ErrInternal("an unexpected error occurred")

// Database or external service down
contract.ErrUnavailable("service temporarily unavailable")

// Feature not built yet
contract.ErrUnimplemented("bulk export coming soon")

// Operation took too long
contract.ErrDeadlineExceeded("request timed out")

Error Code Reference

CodeHTTPWhen to Use
ErrCodeInvalidArgument400Bad input data, validation failures
ErrCodeUnauthenticated401User not logged in, expired token
ErrCodePermissionDenied403User logged in but lacks permission
ErrCodeNotFound404Resource doesn’t exist
ErrCodeAlreadyExists409Resource already exists
ErrCodeFailedPrecondition412Can’t do action in current state
ErrCodeResourceExhausted429Rate limiting, quota exceeded
ErrCodeCanceled499Client canceled the request
ErrCodeInternal500Bugs, unexpected errors
ErrCodeUnimplemented501Feature not built yet
ErrCodeUnavailable503Service down, maintenance
ErrCodeDeadlineExceeded504Operation timed out

Adding Context to Errors

Adding Details

Details help clients understand and handle errors better. They appear in the error response but don’t change the message:
// Add multiple details at once
err := contract.ErrInvalidArgument("validation failed").
    WithDetails(map[string]any{
        "field":  "email",
        "reason": "must be a valid email address",
        "value":  "not-an-email",
    })

// Add single detail
err := contract.ErrNotFound("todo not found").
    WithDetail("todoId", requestedID)
Details appear in the JSON response:
{
    "code": "INVALID_ARGUMENT",
    "message": "validation failed",
    "details": {
        "field": "email",
        "reason": "must be a valid email address",
        "value": "not-an-email"
    }
}
This is incredibly useful for:
  • Frontend developers: Can show “Please enter a valid email address” next to the email field
  • API consumers: Can programmatically handle specific error cases
  • Debugging: Includes context about what went wrong

Wrapping Underlying Errors

Preserve the original error for debugging while showing a user-friendly message:
package todo

func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    todo, err := s.db.FindByID(in.ID)
    if err != nil {
        // sql.ErrNoRows -> user-friendly "not found"
        if errors.Is(err, sql.ErrNoRows) {
            return nil, contract.ErrNotFound("todo not found")
        }
        // Other database errors -> preserve for logging, show generic message
        return nil, contract.ErrInternal("database error").WithCause(err)
    }
    return todo, nil
}
The WithCause method:
  • Preserves the original error for logging and debugging
  • Hides internal details from API responses (security!)
  • Supports errors.Is() and errors.As() for checking causes
// In logging middleware
if cause := errors.Unwrap(err); cause != nil {
    log.Printf("Original error: %v", cause)  // "connection refused"
}
// API response shows: "database error" (not the internal details)

Complete Example

Here’s a realistic service with proper error handling throughout:
// user/service.go
package user

import (
    "context"
    "database/sql"
    "errors"
    "regexp"

    contract "github.com/go-mizu/mizu/contract/v2"
)

// API defines user management operations
type API interface {
    Create(ctx context.Context, in *CreateInput) (*User, error)
    Get(ctx context.Context, in *GetInput) (*User, error)
    Delete(ctx context.Context, in *DeleteInput) error
}

// Service implements user.API
type Service struct {
    db *sql.DB
}

var _ API = (*Service)(nil)

var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)

func (s *Service) Create(ctx context.Context, in *CreateInput) (*User, error) {
    // Validate input - check each field
    if in.Email == "" {
        return nil, contract.ErrInvalidArgument("email is required")
    }
    if !emailRegex.MatchString(in.Email) {
        return nil, contract.ErrInvalidArgument("invalid email format").
            WithDetail("field", "email").
            WithDetail("value", in.Email)
    }
    if len(in.Password) < 8 {
        return nil, contract.ErrInvalidArgument("password must be at least 8 characters").
            WithDetail("field", "password")
    }
    if in.Name == "" {
        return nil, contract.ErrInvalidArgument("name is required")
    }

    // Check for duplicates
    exists, err := s.userExists(ctx, in.Email)
    if err != nil {
        return nil, contract.ErrInternal("failed to check user").WithCause(err)
    }
    if exists {
        return nil, contract.ErrAlreadyExists("user with this email already exists").
            WithDetail("email", in.Email)
    }

    // Create the user
    user, err := s.insertUser(ctx, in)
    if err != nil {
        return nil, contract.ErrInternal("failed to create user").WithCause(err)
    }

    return user, nil
}

func (s *Service) Get(ctx context.Context, in *GetInput) (*User, error) {
    if in.ID == "" {
        return nil, contract.ErrInvalidArgument("id is required")
    }

    user, err := s.findUser(ctx, in.ID)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, contract.ErrNotFound("user not found").
                WithDetail("userId", in.ID)
        }
        return nil, contract.ErrInternal("failed to fetch user").WithCause(err)
    }

    return user, nil
}

func (s *Service) Delete(ctx context.Context, in *DeleteInput) error {
    // Get current user from context (set by auth middleware)
    currentUserID, ok := ctx.Value("userID").(string)
    if !ok {
        return contract.ErrUnauthenticated("not authenticated")
    }

    // Users can only delete their own account (unless admin)
    if in.ID != currentUserID {
        isAdmin, _ := ctx.Value("isAdmin").(bool)
        if !isAdmin {
            return contract.ErrPermissionDenied("you can only delete your own account")
        }
    }

    // Check user exists before deleting
    _, err := s.findUser(ctx, in.ID)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return contract.ErrNotFound("user not found")
        }
        return contract.ErrInternal("failed to check user").WithCause(err)
    }

    // Delete the user
    if err := s.deleteUser(ctx, in.ID); err != nil {
        return contract.ErrInternal("failed to delete user").WithCause(err)
    }

    return nil
}

// Helper methods (implementation details)
func (s *Service) userExists(ctx context.Context, email string) (bool, error) { /* ... */ }
func (s *Service) findUser(ctx context.Context, id string) (*User, error)     { /* ... */ }
func (s *Service) insertUser(ctx context.Context, in *CreateInput) (*User, error) { /* ... */ }
func (s *Service) deleteUser(ctx context.Context, id string) error { /* ... */ }

Best Practices

1. Use Specific Error Codes

Specific codes map to correct HTTP status codes:
// Good: specific codes map to correct HTTP status
contract.ErrNotFound("order not found")      // -> HTTP 404
contract.ErrInvalidArgument("bad email")     // -> HTTP 400

// Bad: using Internal for everything
contract.ErrInternal("not found")            // -> HTTP 500 (wrong!)

2. Write User-Friendly Messages

Messages should tell users what went wrong and what to do:
// Good: tells user what to do
contract.ErrInvalidArgument("email must be like [email protected]")
contract.ErrPermissionDenied("upgrade to Pro to access this feature")

// Bad: cryptic messages
contract.ErrInvalidArgument("invalid")
contract.ErrPermissionDenied("denied")

3. Include Relevant Details

Details help clients handle errors intelligently:
// Good: client can show "Order #12345 not found"
contract.ErrNotFound("order not found").WithDetail("orderId", "12345")

// Good: client can highlight the email field
contract.ErrInvalidArgument("invalid email").WithDetail("field", "email")

// Bad: no context
contract.ErrNotFound("not found")

4. Don’t Leak Internal Details

Protect your infrastructure from exposure:
// Good: safe, user-friendly
contract.ErrInternal("database temporarily unavailable")

// Bad: exposes infrastructure
contract.ErrInternal("connection to postgres at 10.0.0.5:5432 refused")
contract.ErrInternal("query timeout after 30s on users table")

5. Handle Each Error Case

Map different underlying errors to appropriate codes:
package todo

func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    if in.ID == "" {
        return nil, contract.ErrInvalidArgument("id is required")
    }

    todo, err := s.db.Find(in.ID)
    if err != nil {
        // Handle each error type appropriately
        if errors.Is(err, sql.ErrNoRows) {
            return nil, contract.ErrNotFound("todo not found")
        }
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, contract.ErrDeadlineExceeded("request timed out")
        }
        if errors.Is(err, context.Canceled) {
            return nil, contract.ErrCanceled("request was canceled")
        }
        // Unknown errors -> Internal
        return nil, contract.ErrInternal("failed to fetch todo").WithCause(err)
    }

    return todo, nil
}

Testing Errors

Test that your errors have the correct code and details:
package user_test

import (
    "context"
    "errors"
    "testing"

    contract "github.com/go-mizu/mizu/contract/v2"
    "yourapp/user"
)

func TestGet_NotFound(t *testing.T) {
    svc := &user.Service{DB: mockDB}
    _, err := svc.Get(context.Background(), &user.GetInput{ID: "nonexistent"})

    // Check it's a contract error
    var contractErr *contract.Error
    if !errors.As(err, &contractErr) {
        t.Fatalf("expected contract.Error, got %T", err)
    }

    // Check the code
    if contractErr.Code != contract.ErrCodeNotFound {
        t.Errorf("expected NOT_FOUND, got %s", contractErr.Code)
    }

    // Check HTTP status
    if contractErr.HTTPStatus() != 404 {
        t.Errorf("expected HTTP 404, got %d", contractErr.HTTPStatus())
    }
}

func TestCreate_Validation(t *testing.T) {
    svc := &user.Service{DB: mockDB}

    tests := []struct {
        name     string
        input    *user.CreateInput
        wantCode contract.ErrCode
    }{
        {"empty email", &user.CreateInput{Email: ""}, contract.ErrCodeInvalidArgument},
        {"bad email", &user.CreateInput{Email: "not-email"}, contract.ErrCodeInvalidArgument},
        {"short password", &user.CreateInput{Email: "[email protected]", Password: "short"}, contract.ErrCodeInvalidArgument},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := svc.Create(context.Background(), tt.input)

            var contractErr *contract.Error
            if !errors.As(err, &contractErr) {
                t.Fatalf("expected contract.Error, got %T", err)
            }

            if contractErr.Code != tt.wantCode {
                t.Errorf("got %s, want %s", contractErr.Code, tt.wantCode)
            }
        })
    }
}

Common Questions

What happens if I return a regular Go error?

Regular errors are treated as Internal errors (HTTP 500):
// This becomes HTTP 500
return nil, errors.New("user not found")

// This becomes HTTP 404 (correct!)
return nil, contract.ErrNotFound("user not found")
Rule of thumb: Always use Contract errors for expected error cases. Use regular errors only for unexpected bugs that shouldn’t happen.

Can I create custom error codes?

Contract uses standard codes that map consistently across protocols. For custom error semantics, use the details field:
contract.ErrInvalidArgument("validation failed").
    WithDetail("customCode", "DUPLICATE_SKU").
    WithDetail("field", "sku")

Should I log errors in my service?

Your service should return errors, not log them. Let middleware handle logging. This keeps your service focused and testable:
// In your service - just return the error
func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    // ...
    return nil, contract.ErrInternal("database error").WithCause(dbErr)
}

// In middleware - log and let it propagate
func loggingMiddleware(next mizu.Handler) mizu.Handler {
    return func(c *mizu.Ctx) error {
        err := next(c)
        if err != nil {
            log.Printf("error: %v", err)
            // Log the original cause too
            if cause := errors.Unwrap(err); cause != nil {
                log.Printf("cause: %v", cause)
            }
        }
        return err
    }
}

What’s Next?

Now that you understand error handling: