Centralize HTTP Error Handling in Go

May 11, 2025

In this short post, I'll share with you a simple pattern I use to centralize error handling for my HTTP handlers.

If you've written any amount of Go HTTP servers, you've probably gotten tired of writing the same error handling code over and over again:

func SomeHandler(w http.ResponseWriter, r *http.Request) {
    data, err := fetchSomeData()
    if err != nil {
        http.Error(w, "Failed to fetch data", http.StatusInternalServerError)
        log.Printf("Error fetching data: %v", err)
        return
    }
    
    // More if-err blocks...
}

This code is repetitive, and clutters your handlers with boilerplate instead of business logic.

A Better Way

The core idea is simple: change your handlers to return errors instead of handling them directly.

Step 1: Define Custom HTTP Errors

package httperror

import (
    "errors"
    "net/http"
)

type HTTPError struct {
    error
    Code int
}

func New(code int, message string) *HTTPError {
    return &HTTPError{
        error: errors.New(message),
        Code:  code,
    }
}

func NotFound(message string) *HTTPError {
    return New(http.StatusNotFound, message)
}

// Add more helpers as needed...

Step 2: Create a Handler Wrapper

// Define a new handler type that returns an error
type HTTPHandlerWithErr func(http.ResponseWriter, *http.Request) error

// Handle wraps your error-returning handlers
func (r *Router) Handle(pattern string, handler HTTPHandlerWithErr) {
    r.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
        if err := handler(w, r); err != nil {
            // Check if it's an HTTPError
            var httpErr *httperror.HTTPError
            if errors.As(err, &httpErr) {
                http.Error(w, err.Error(), httpErr.Code)
                slog.Debug("http error", "code", httpErr.Code, "err", err.Error())
            } else {
                // Default to 500
                http.Error(w, err.Error(), http.StatusInternalServerError)
                slog.Error("internal server error", "err", err.Error())
            }
        }
    })
}

This wrapper does all the error handling heavy lifting. It uses errors.As() to check if the error is an HTTPError and extract the status code.

Step 3: Add Method-Specific Helpers

func (r *Router) Get(pattern string, handler HTTPHandlerWithErr) {
    r.Handle("GET "+pattern, handler)
}

// Add Post, Put, Patch, Delete methods...

Step 4: Write Clean Handlers

func (c *ContainersController) Show(w http.ResponseWriter, r *http.Request) error {
    id := r.PathValue("id")
    
    container, err := c.service.FindContainer(id)
    if err != nil {
        if errors.Is(err, store.ErrNotFound) {
            return httperror.NotFound("container not found")
        }
        return err
    }
    
    return json.NewEncoder(w).Encode(container)
}

Look how clean this handler is now! It focuses on its job instead of error handling gymnastics.

What's Next?

Once you've got this pattern in place, you can:

  1. Use JSON responses: Return errors as JSON for API endpoints
  2. Add request IDs: Thread request IDs through logs and responses
  3. Build error-aware middleware: Create middleware that works with your error-returning handlers
  4. Improve error pages: Replace plain text errors with proper error pages

This pattern works with any router that accepts standard Go handlers. It's a small change that makes a huge difference in code quality and maintainability.

Join Discord Community