DEV Community

Cover image for Centralize HTTP Error Handling in Go
Alexis Bouchez
Alexis Bouchez

Posted on

Centralize HTTP Error Handling in Go

Originally posted here

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...
}
Enter fullscreen mode Exit fullscreen mode

This code is repetitive, error-prone, 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...
Enter fullscreen mode Exit fullscreen mode

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())
            }
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)