Learn Chi - Error Handling & Logging
Series/Learn Chi/Episode 11
Episode 11 of 23

Learn Chi - Error Handling & Logging

This episode builds a centralized error handling system: a custom error type with status codes, handlers that return errors to JSON, and recovery middleware to catch panics. You will also adopt slog for structured logging and build a request logging middleware.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Handlers that copy-paste http.Error blocks with different messages produce inconsistent APIs and useless logs. Episode 11 builds a centralized system: all errors flow through the same path, status codes stay consistent, and every request is recorded in a structured log.

Go has shipped log/slog since version 1.21 — a built-in structured logger that needs no extra dependencies. Combining a custom error type, a handler adapter, and slog makes production debugging feel much lighter.

Custom Error Type

Errors with a Status Code

Create an error type that carries HTTP information:

Custom error type
type APIError struct {
    Code    int
    Message string
    Err     error
}
 
func (e *APIError) Error() string {
    return e.Message
}
 
func NewAPIError(code int, msg string) *APIError {
    return &APIError{Code: code, Message: msg}
}

NewAPIError(code, msg) creates an error that knows its status code. Handlers simply return the error; middleware or an adapter translates it into a response.

Wrapping the Underlying Error

Wrap errors with %w
func NewAPIErrorf(code int, msg string, err error) *APIError {
    return &APIError{Code: code, Message: msg, Err: err}
}

errors.Is and errors.As still work when an error is wrapped with %w because APIError implements Unwrap. This keeps the error chain traceable.

Handlers That Return Errors

Handler Adapter

Instead of changing the http.Handler contract, wrap handlers that return errors:

Handler error adapter
func Handle(fn func(w http.ResponseWriter, req *http.Request) error) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        if err := fn(w, req); err != nil {
            var apiErr *APIError
            if errors.As(err, &apiErr) {
                writeJSON(w, apiErr.Code,
                    map[string]string{"error": apiErr.Message})
                return
            }
            slog.Error("unexpected error", "error", err)
            writeJSON(w, http.StatusInternalServerError,
                map[string]string{"error": "internal server error"})
        }
    }
}

errors.As(err, &apiErr) checks whether the error is an APIError. If it isn't, log it as an unexpected error and return 500 — error details never leak to the client.

Clean Handlers

With the adapter, handlers stay clean:

Handler returning an error
r.Get("/users/{id}", Handle(func(w http.ResponseWriter, req *http.Request) error {
    id, _ := strconv.Atoi(chi.URLParam(req, "id"))
    user, err := repo.FindByID(req.Context(), id)
    if err != nil {
        return NewAPIError(http.StatusNotFound, "user tidak ditemukan")
    }
    writeJSON(w, http.StatusOK, user)
    return nil
}))

Handle(...) wraps the handler so error logic is centralized in one place, not scattered across every handler.

Recovery Middleware

Catching Panics

middleware.Recoverer prevents the server from dying on a panic:

Install Recoverer
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(requestLogger)

r.Use(middleware.Recoverer) catches panics in handlers, prints the stack trace to the log, and returns a 500 status to the client. The server stays alive to serve the next request.

Custom Recoverer

To return JSON on a panic:

Custom JSON Recoverer
func jsonRecoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        defer func() {
            if r := recover(); r != nil {
                slog.Error("panic tertangkap",
                    "recovered", r, "path", req.URL.Path)
                writeJSON(w, http.StatusInternalServerError,
                    map[string]string{"error": "internal server error"})
            }
        }()
        next.ServeHTTP(w, req)
    })
}

recover() captures the panic value before it propagates, then the middleware writes a consistent JSON response.

Logging with slog

Structured Logger Setup

log/slog produces JSON that's ready to parse:

slog setup
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)

slog.NewJSONHandler(os.Stdout, nil) outputs JSON-formatted logs — readable by both humans and machines. slog.SetDefault makes every package use the same logger.

Request Logging Middleware

Build a middleware that logs every request:

Request logging middleware
func requestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, req)
        slog.Info("request selesai",
            "method", req.Method,
            "path", req.URL.Path,
            "durasi", time.Since(start))
    })
}

slog.Info("request selesai", "method", req.Method, "path", req.URL.Path) records structured attributes — not sprintf'd strings. This is what makes logs filterable and aggregatable.

Choosing the Right Log Level

  • slog.Debug for details only needed while debugging.
  • slog.Info for the normal flow, such as a finished request.
  • slog.Warn for suspicious conditions that aren't fatal yet.
  • slog.Error for failures that need human attention.

Conclusion

Key takeaways:

  • APIError carries a status code; errors.As detects it.
  • The Handle adapter centralizes error-to-JSON translation.
  • Unknown errors return 500 without leaking details.
  • middleware.Recoverer prevents the server from dying on a panic.
  • Go's built-in slog writes structured JSON logs.
  • A logging middleware records method, path, and duration for every request.

In the next episode 12 we explore the request lifecycle: context, timeout, and concurrency — request-scoped values, request cancellation, middleware.Timeout, goroutines in handlers, and middleware.Throttle with synchronization of shared state.

Learn Chi - Error Handling & Logging | Learn Chi