Skip to main content
Mizu simplifies error handling by letting you return errors directly from handlers. This keeps your code clean and ensures consistent error responses across your entire application.

Error Handling Philosophy

In Mizu, error handling follows a simple principle: handlers return errors, the framework handles them. This means:
  1. Your handlers focus on business logic
  2. Errors flow up naturally through return err
  3. One central error handler formats all responses
  4. Panics are caught and treated like errors
This approach eliminates scattered error handling code and ensures every error is logged and responded to appropriately.

Returning Errors from Handlers

Any handler can return an error to indicate something went wrong:
package main

import (
    "fmt"
    "github.com/go-mizu/mizu"
)

func getUser(c *mizu.Ctx) error {
    id := c.Param("id")

    user, err := findUser(id)
    if err != nil {
        // Just return the error - Mizu will handle it
        return err
    }

    return c.JSON(200, user)
}

func main() {
    app := mizu.New()
    app.Get("/users/{id}", getUser)
    app.Listen(":3000")
}

Default Behavior

If you don’t configure a custom error handler, Mizu will:
  1. Log the error with the request context
  2. Return a 500 Internal Server Error to the client
  3. Include a generic error message (no sensitive details exposed)
GET /users/999
11:23:45 ERR request failed error="user not found" path=/users/999
Response: 500 Internal Server Error

The ErrorHandler

Define a global error handler to customize how all errors are processed:
func errorHandler(c *mizu.Ctx, err error) {
    // Log the error
    c.Logger().Error("request failed", "error", err)

    // Send a response
    _ = c.JSON(500, map[string]string{
        "error": "internal server error",
    })
}

func main() {
    app := mizu.New()
    app.ErrorHandler(errorHandler)

    app.Get("/", handler)
    app.Listen(":3000")
}
The error handler receives:
  • c - The request context (same as handlers)
  • err - The error returned from the handler
The error handler should always send a response. If it doesn’t, the client will receive an empty response.

Custom Error Types

Create custom error types to carry additional information like HTTP status codes:

Defining Custom Errors

// HTTPError carries an HTTP status code with the error
type HTTPError struct {
    Code    int
    Message string
    Err     error // Original error (optional)
}

func (e *HTTPError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

func (e *HTTPError) Unwrap() error {
    return e.Err
}

// Helper constructors
func NotFound(message string) *HTTPError {
    return &HTTPError{Code: 404, Message: message}
}

func BadRequest(message string) *HTTPError {
    return &HTTPError{Code: 400, Message: message}
}

func Unauthorized(message string) *HTTPError {
    return &HTTPError{Code: 401, Message: message}
}

func InternalError(err error) *HTTPError {
    return &HTTPError{Code: 500, Message: "internal server error", Err: err}
}

Using Custom Errors in Handlers

func getUser(c *mizu.Ctx) error {
    id := c.Param("id")
    if id == "" {
        return BadRequest("user ID is required")
    }

    user, err := findUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return NotFound("user not found")
        }
        return InternalError(err)
    }

    return c.JSON(200, user)
}

Handling Custom Errors

func errorHandler(c *mizu.Ctx, err error) {
    // Check for HTTPError
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        // Log at appropriate level
        if httpErr.Code >= 500 {
            c.Logger().Error("server error",
                "code", httpErr.Code,
                "error", err)
        } else {
            c.Logger().Warn("client error",
                "code", httpErr.Code,
                "error", err)
        }

        _ = c.JSON(httpErr.Code, map[string]string{
            "error": httpErr.Message,
        })
        return
    }

    // Unknown error - treat as 500
    c.Logger().Error("unexpected error", "error", err)
    _ = c.JSON(500, map[string]string{
        "error": "internal server error",
    })
}

HTTP Error Responses

RFC 7807 Problem Details

For production APIs, consider using the RFC 7807 problem details format:
type ProblemDetail struct {
    Type     string `json:"type"`
    Title    string `json:"title"`
    Status   int    `json:"status"`
    Detail   string `json:"detail,omitempty"`
    Instance string `json:"instance,omitempty"`
}

func errorHandler(c *mizu.Ctx, err error) {
    var httpErr *HTTPError
    if !errors.As(err, &httpErr) {
        httpErr = InternalError(err)
    }

    problem := ProblemDetail{
        Type:     "https://api.example.com/errors/" + statusName(httpErr.Code),
        Title:    http.StatusText(httpErr.Code),
        Status:   httpErr.Code,
        Detail:   httpErr.Message,
        Instance: c.Request().URL.Path,
    }

    c.Header().Set("Content-Type", "application/problem+json")
    _ = c.JSON(httpErr.Code, problem)
}
Example response:
{
  "type": "https://api.example.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "User with ID 123 not found",
  "instance": "/users/123"
}

Different Response Formats

Serve different error formats based on client expectations:
func errorHandler(c *mizu.Ctx, err error) {
    code := 500
    message := "internal server error"

    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        code = httpErr.Code
        message = httpErr.Message
    }

    // Check Accept header
    accept := c.Request().Header.Get("Accept")

    switch {
    case strings.Contains(accept, "application/json"):
        _ = c.JSON(code, map[string]string{"error": message})

    case strings.Contains(accept, "text/html"):
        _ = c.HTML(code, fmt.Sprintf("<h1>Error %d</h1><p>%s</p>", code, message))

    default:
        _ = c.Text(code, message)
    }
}

Error Wrapping with Context

Use Go’s error wrapping to add context as errors bubble up:
import "fmt"

func getUserOrders(c *mizu.Ctx) error {
    userID := c.Param("id")

    user, err := findUser(userID)
    if err != nil {
        return fmt.Errorf("fetching user %s: %w", userID, err)
    }

    orders, err := findOrders(user.ID)
    if err != nil {
        return fmt.Errorf("fetching orders for user %s: %w", userID, err)
    }

    return c.JSON(200, orders)
}

Unwrapping in Error Handler

func errorHandler(c *mizu.Ctx, err error) {
    // Log the full error chain
    c.Logger().Error("request failed", "error", err)

    // Check for specific errors
    if errors.Is(err, sql.ErrNoRows) {
        _ = c.JSON(404, map[string]string{"error": "not found"})
        return
    }

    if errors.Is(err, context.DeadlineExceeded) {
        _ = c.JSON(504, map[string]string{"error": "request timeout"})
        return
    }

    // Check for custom error types
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        _ = c.JSON(400, map[string]any{
            "error":  "validation failed",
            "fields": validationErr.Fields,
        })
        return
    }

    _ = c.JSON(500, map[string]string{"error": "internal server error"})
}

Panic Recovery

Mizu automatically recovers from panics and converts them to errors. This prevents your server from crashing:
func riskyHandler(c *mizu.Ctx) error {
    // If this panics, Mizu catches it
    data := loadData()
    result := data[0] // Panic if data is empty!
    return c.JSON(200, result)
}

Handling Panics

Panics are wrapped in *mizu.PanicError:
func errorHandler(c *mizu.Ctx, err error) {
    // Check if this was a panic
    var panicErr *mizu.PanicError
    if errors.As(err, &panicErr) {
        // Log the panic with stack trace
        c.Logger().Error("panic recovered",
            "value", panicErr.Value,
            "stack", string(panicErr.Stack),
        )

        _ = c.JSON(500, map[string]string{
            "error": "internal server error",
        })
        return
    }

    // Handle regular errors
    c.Logger().Error("error", "err", err)
    _ = c.JSON(500, map[string]string{"error": "internal server error"})
}

When Panics Occur

Common causes of panics:
  • Nil pointer dereference
  • Index out of bounds
  • Type assertion failure
  • Divide by zero
// These will all panic but Mizu will recover:
var ptr *User
name := ptr.Name  // Nil pointer!

slice := []int{}
first := slice[0]  // Index out of bounds!

var val any = "string"
num := val.(int)  // Type assertion failure!
While Mizu recovers from panics, you should still write defensive code. Panics are expensive and should be exceptions, not the norm.

Error Logging

Log Levels by Error Type

func errorHandler(c *mizu.Ctx, err error) {
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        switch {
        case httpErr.Code >= 500:
            // Server errors - always log
            c.Logger().Error("server error",
                "code", httpErr.Code,
                "error", err,
                "path", c.Request().URL.Path,
            )
        case httpErr.Code >= 400:
            // Client errors - log as warning
            c.Logger().Warn("client error",
                "code", httpErr.Code,
                "error", httpErr.Message,
            )
        }
    } else {
        // Unknown error
        c.Logger().Error("unexpected error", "error", err)
    }

    // Send response...
}

Including Request Context

func errorHandler(c *mizu.Ctx, err error) {
    req := c.Request()

    c.Logger().Error("request failed",
        "error", err,
        "method", req.Method,
        "path", req.URL.Path,
        "query", req.URL.RawQuery,
        "remote_ip", c.IP(),
        "user_agent", req.UserAgent(),
    )

    // Send response...
}

Error Monitoring Integration

Sending to Sentry

import "github.com/getsentry/sentry-go"

func errorHandler(c *mizu.Ctx, err error) {
    var httpErr *HTTPError
    if !errors.As(err, &httpErr) || httpErr.Code >= 500 {
        // Send server errors to Sentry
        sentry.WithScope(func(scope *sentry.Scope) {
            scope.SetRequest(c.Request())
            scope.SetTag("path", c.Request().URL.Path)
            sentry.CaptureException(err)
        })
    }

    // Send response...
}

Custom Metrics

var (
    errorCounter = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_errors_total",
            Help: "Total HTTP errors by code and path",
        },
        []string{"code", "path"},
    )
)

func errorHandler(c *mizu.Ctx, err error) {
    code := 500
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        code = httpErr.Code
    }

    // Record metric
    errorCounter.WithLabelValues(
        strconv.Itoa(code),
        c.Request().URL.Path,
    ).Inc()

    // Send response...
}

Testing Error Scenarios

Testing Error Responses

func TestNotFoundError(t *testing.T) {
    app := mizu.New()
    app.ErrorHandler(errorHandler)
    app.Get("/users/{id}", getUser)

    req := httptest.NewRequest("GET", "/users/nonexistent", nil)
    rec := httptest.NewRecorder()

    app.ServeHTTP(rec, req)

    if rec.Code != 404 {
        t.Errorf("expected 404, got %d", rec.Code)
    }

    var response map[string]string
    json.Unmarshal(rec.Body.Bytes(), &response)

    if response["error"] != "user not found" {
        t.Errorf("unexpected error message: %s", response["error"])
    }
}

Testing Panic Recovery

func TestPanicRecovery(t *testing.T) {
    app := mizu.New()
    app.ErrorHandler(errorHandler)
    app.Get("/panic", func(c *mizu.Ctx) error {
        panic("test panic")
    })

    req := httptest.NewRequest("GET", "/panic", nil)
    rec := httptest.NewRecorder()

    // Should not panic
    app.ServeHTTP(rec, req)

    if rec.Code != 500 {
        t.Errorf("expected 500, got %d", rec.Code)
    }
}

Testing Custom Error Types

func TestValidationError(t *testing.T) {
    app := mizu.New()
    app.ErrorHandler(errorHandler)
    app.Post("/users", createUser)

    // Send invalid data
    body := strings.NewReader(`{"name": ""}`)
    req := httptest.NewRequest("POST", "/users", body)
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    app.ServeHTTP(rec, req)

    if rec.Code != 400 {
        t.Errorf("expected 400, got %d", rec.Code)
    }
}

Complete Example

Here’s a complete example with custom errors, error handler, and multiple routes:
package main

import (
    "database/sql"
    "errors"
    "fmt"
    "net/http"

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

// Custom error types
type HTTPError struct {
    Code    int
    Message string
    Err     error
}

func (e *HTTPError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

func (e *HTTPError) Unwrap() error { return e.Err }

func NotFound(msg string) *HTTPError {
    return &HTTPError{Code: 404, Message: msg}
}

func BadRequest(msg string) *HTTPError {
    return &HTTPError{Code: 400, Message: msg}
}

// Error handler
func errorHandler(c *mizu.Ctx, err error) {
    code := 500
    message := "internal server error"

    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        code = httpErr.Code
        message = httpErr.Message
    }

    // Check for panic
    var panicErr *mizu.PanicError
    if errors.As(err, &panicErr) {
        c.Logger().Error("panic",
            "value", panicErr.Value,
            "stack", string(panicErr.Stack))
    } else if code >= 500 {
        c.Logger().Error("error", "err", err)
    } else {
        c.Logger().Warn("error", "err", err)
    }

    _ = c.JSON(code, map[string]any{
        "error": message,
        "code":  code,
    })
}

// Handlers
func getUser(c *mizu.Ctx) error {
    id := c.Param("id")
    if id == "" {
        return BadRequest("user ID required")
    }

    // Simulate database lookup
    if id == "0" {
        return NotFound("user not found")
    }

    return c.JSON(200, map[string]any{
        "id":   id,
        "name": "Alice",
    })
}

func createUser(c *mizu.Ctx) error {
    var input struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    if err := c.BindJSON(&input); err != nil {
        return BadRequest("invalid JSON")
    }

    if input.Name == "" {
        return BadRequest("name is required")
    }

    if input.Email == "" {
        return BadRequest("email is required")
    }

    return c.JSON(201, map[string]any{
        "id":    "new-id",
        "name":  input.Name,
        "email": input.Email,
    })
}

func main() {
    app := mizu.New()

    // Set global error handler
    app.ErrorHandler(errorHandler)

    // Routes
    app.Get("/users/{id}", getUser)
    app.Post("/users", createUser)

    app.Listen(":3000")
}

Best Practices

Do

// DO: Return errors, don't handle them inline
func handler(c *mizu.Ctx) error {
    data, err := fetchData()
    if err != nil {
        return err  // Let error handler deal with it
    }
    return c.JSON(200, data)
}

// DO: Use custom error types for client errors
return NotFound("user not found")
return BadRequest("invalid email format")

// DO: Wrap errors with context
return fmt.Errorf("fetching user %s: %w", id, err)

// DO: Log with structured fields
c.Logger().Error("failed", "error", err, "user_id", id)

Don’t

// DON'T: Send response AND return error
c.JSON(400, map[string]string{"error": "bad"})
return errors.New("bad request")  // Response already sent!

// DON'T: Expose internal errors to clients
return c.JSON(500, map[string]string{
    "error": err.Error(),  // May expose sensitive info!
})

// DON'T: Ignore errors
data, _ := fetchData()  // What if this fails?

// DON'T: Panic for expected conditions
if user == nil {
    panic("user not found")  // Use error instead!
}

Summary

ConceptDescription
Return errorsHandlers return error, Mizu handles them
ErrorHandlerOne global handler for all errors
Custom errorsCreate types with status codes
Error wrappingUse %w to add context
Panic recoveryAutomatic, converts to PanicError
Error loggingInclude context for debugging

What’s Next

  • Middleware - Error handling in middleware
  • Logging - Structured logging for errors
  • Context - Request context and cancellation
  • Response - Sending error responses