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:
- Your handlers focus on business logic
- Errors flow up naturally through
return err
- One central error handler formats all responses
- 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:
- Log the error with the request context
- Return a
500 Internal Server Error to the client
- 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"
}
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: 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
| Concept | Description |
|---|
| Return errors | Handlers return error, Mizu handles them |
| ErrorHandler | One global handler for all errors |
| Custom errors | Create types with status codes |
| Error wrapping | Use %w to add context |
| Panic recovery | Automatic, converts to PanicError |
| Error logging | Include context for debugging |
Whatβs Next