Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 47

Centralized API Error Handling

Recommended resources

Hello! Welcome to our next lesson on building robust web services in Go.

In our last session, you learned how to use the middleware pattern to centralize cross-cutting concerns like logging and authentication. This is a powerful way to keep your handler logic clean and focused on business value.

Today, we'll build on that foundation to tackle one of the most important aspects of API design: error handling. You've probably noticed that standard http.Handlers don't return errors, which can lead to repetitive and inconsistent error responses scattered throughout your code.

In this lesson, you will learn how to implement a centralized error handling middleware for consistent API responses. This will allow you to:

  • Simplify your handler logic by returning errors directly.
  • Define custom error types that map to specific HTTP status codes.
  • Automatically generate structured, consistent JSON error responses for all your endpoints.
  • Safely handle unexpected internal errors by logging them for developers while showing a generic error to users.

This pattern is fundamental to creating professional, reliable, and secure APIs.

Go HTTP Request Flow with Middleware
Just like with logging and auth, our error handling will be implemented as a middleware that sits between the server and your application handlers, providing a centralized point of control.

1. The Problem: Inconsistent Handler Logic

Let's start by looking at a typical handler without a centralized strategy.

func someHandler(w http.ResponseWriter, r *http.Request) {
    // 1. Validation error
    id := r.URL.Query().Get("id")
    if id == "" {
        http.Error(w, `{"error": "missing id"}`, http.StatusBadRequest)
        return
    }

    // 2. Database error
    _, err := findItemInDB(id)
    if err != nil {
        // Is this a "not found" error or a true internal error?
        // We have to inspect the error here.
        if errors.Is(err, sql.ErrNoRows) {
            http.Error(w, `{"error": "item not found"}`, http.StatusNotFound)
            return
        }
        // It's some other DB error. Log it and return a generic 500.
        log.Printf("database error: %v", err)
        http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError)
        return
    }
    
    // ... happy path ...
}

As you can see, the handler is cluttered with http.Error calls, status code decisions, and logging logic. This code has to be duplicated in every handler, making it hard to maintain and ensuring consistency is a challenge. Your experience with Python frameworks likely provided decorators or exception handlers to solve this; we'll now build the idiomatic Go equivalent.


2. A Cleaner Approach: Error-Returning Handlers

The core of a better solution is to change the signature of our handlers so they can return an error. This allows us to use the standard Go idiom: if err != nil { return err }.

// Define a custom handler type that can return an error.
type AppHandler func(w http.ResponseWriter, r *http.Request) error

// The handler logic becomes much cleaner.
func someHandler(w http.ResponseWriter, r *http.Request) error {
    id := r.URL.Query().Get("id")
    if id == "" {
        // How do we specify a 400 Bad Request here? We'll solve this next.
        return fmt.Errorf("missing id") 
    }

    _, err := findItemInDB(id)
    if err != nil {
        return err // Just return the error.
    }

    // ... happy path ...
    return nil
}

This is much cleaner, but it introduces two new problems:

  1. The standard net/http server doesn't know how to work with a handler that returns an error.
  2. A simple error doesn't contain enough information to decide the HTTP status code or the JSON response format.

Let's solve these one by one.


3. The Middleware Adapter and Custom Errors

We'll solve both problems using a combination of a middleware "adapter" and a custom error type.

  1. The Adapter: A function that takes our custom AppHandler and returns a standard http.HandlerFunc. This adapter will execute our handler, capture the returned error, and be responsible for writing the final HTTP response.
  2. Custom Error Type: A struct that implements the error interface but also carries extra information, like the desired HTTP status code and a user-friendly message.

A Beautiful Way To Deal With ERRORS in Golang HTTP Handlers

The video 'A Beautiful Way To Deal With ERRORS in Golang HTTP Handlers' by Anthony GG provides an excellent walkthrough of this exact pattern. It shows how to define a custom error and use a wrapper function to handle it centrally.

First, watch the introduction (00:00 - 01:46) to see the problem demonstrated. Then, watch the section on the custom APIError struct (03:49 - 05:19). Pay close attention to how the APIError struct holds both a status code and a message, and how it satisfies the error interface.

Let's define our own APIError struct based on this idea.

// APIError represents a structured error response.
type APIError struct {
	Status  int    // The HTTP status code (e.g., 400, 404, 500)
	Message string // A user-friendly message
	err     error  // The original internal error, for logging
}

// Error makes APIError satisfy the error interface.
// We return the internal error's message for logging purposes.
func (e *APIError) Error() string {
	if e.err != nil {
		return e.err.Error()
	}
	return e.Message
}

Now, our handlers can return an *APIError to signal a specific, structured response. For example:

func findWidgetHandler(w http.ResponseWriter, r *http.Request) error {
    // ...
    widget, err := db.GetWidget(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return &APIError{
                Status:  http.StatusNotFound,
                Message: fmt.Sprintf("widget with id %s not found", id),
                err:     err,
            }
        }
        // For any other DB error, we return it directly.
        // Our middleware will treat it as an internal server error.
        return err 
    }
    // ...
    return nil
}

Notice the separation of concerns. The handler is responsible for identifying what kind of error occurred (e.g., "Not Found"), while the middleware will be responsible for how to present that error to the client.


4. Implementing the Centralized Error Middleware

Now we'll build the middleware adapter. This function will be the heart of our centralized error handling. It will receive any error from the handler and decide what to do.

The logic is as follows:

  1. Execute the application handler.
  2. If the returned error is nil, the request was successful. Do nothing.
  3. If an error is returned, use errors.As to check if it can be treated as our *APIError.
  4. If it is an *APIError, use its Status and Message to write a structured JSON response.
  5. If it's not an *APIError, it's an unexpected error. Log the full error message for debugging and return a generic 500 Internal Server Error to the client, hiding implementation details.

Error handling in Go HTTP applications

The article 'Error handling in Go HTTP applications' by Joe Shaw provides a clear, text-based implementation of this final piece: the centralized error handling function.

Read the section titled 'Handling errors in the API'. It presents a JSONHandleError helper function that contains the exact logic we need. Note the use of errors.As to check the error type and the fallback to a 500 error.

Let's build this logic into our middleware adapter.

// This is our adapter. It takes our custom handler and returns a standard http.Handler.
func (h AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Execute the handler and capture the error.
	err := h(w, r)
	
	if err != nil {
		var apiErr *APIError
		
		// If the error is our custom APIError type...
		if errors.As(err, &apiErr) {
			// Log the original internal error if it exists.
			log.Printf("API Error: %s - %v", apiErr.Message, apiErr.err)
			
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(apiErr.Status)
			json.NewEncoder(w).Encode(map[string]string{"error": apiErr.Message})
			return
		}

		// If it's any other type of error, it's an unexpected internal error.
		log.Printf("Internal Server Error: %v", err)
		
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]string{"error": "an unexpected error occurred"})
	}
}
Test your understanding!

Why is it important for the middleware to distinguish between a custom APIError and a generic error? What are the security implications of treating all errors the same way (e.g., by always showing err.Error() to the user)?

Show answer

Distinguishing between the error types is crucial for both functionality and security.

  • Functionality: Custom APIErrors allow you to communicate specific, expected outcomes to an API client (e.g., 400 Bad Request, 404 Not Found). A generic error represents an unexpected failure.
  • Security: A generic error from a lower-level package (like database/sql or os) might contain sensitive information, such as database connection details, internal file paths, or stack traces. Exposing this information in an API response is a security vulnerability known as "information leakage." By catching generic errors and returning a standard 500 message, you prevent leaking implementation details.

5. Putting It All Together

Let's combine everything into a runnable example. This service will have three endpoints:

  • /health: Always succeeds.
  • /users?id=...: Fails with a 404 Not Found if the user doesn't exist.
  • /panic: Simulates an unexpected internal error.
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
)

// APIError represents a structured error response.
type APIError struct {
	Status  int
	Message string
	err     error // For internal logging
}

func (e *APIError) Error() string {
	if e.err != nil {
		return e.err.Error()
	}
	return e.Message
}

// AppHandler is our custom handler type.
type AppHandler func(http.ResponseWriter, *http.Request) error

// ServeHTTP makes AppHandler satisfy the http.Handler interface.
// This is our centralized error handling middleware.
func (h AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := h(w, r); err != nil {
		var apiErr *APIError
		if errors.As(err, &apiErr) {
			log.Printf("API Error: Status %d, Msg: %s, Err: %v", apiErr.Status, apiErr.Message, apiErr.err)
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(apiErr.Status)
			json.NewEncoder(w).Encode(map[string]string{"error": apiErr.Message})
			return
		}

		log.Printf("Internal Server Error: %v", err)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]string{"error": "an unexpected error occurred"})
	}
}

// --- Handlers ---

func healthCheckHandler(w http.ResponseWriter, r *http.Request) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
	return nil
}

func getUserHandler(w http.ResponseWriter, r *http.Request) error {
	id := r.URL.Query().Get("id")
	if id == "" {
		return &APIError{
			Status:  http.StatusBadRequest,
			Message: "missing user id",
		}
	}

	// Simulate a "not found" error.
	if id != "123" {
		return &APIError{
			Status:  http.StatusNotFound,
			Message: fmt.Sprintf("user with id '%s' not found", id),
			err:     errors.New("database: no rows in result set"), // original error
		}
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{"id": id, "name": "John Doe"})
	return nil
}

func internalErrorHandler(w http.ResponseWriter, r *http.Request) error {
	// This returns a generic error, not an APIError.
	// Our middleware will catch this and turn it into a 500 response.
	return errors.New("a really bad thing happened internally")
}


func main() {
	mux := http.NewServeMux()

	mux.Handle("GET /health", AppHandler(healthCheckHandler))
	mux.Handle("GET /users", AppHandler(getUserHandler))
	mux.Handle("GET /internal", AppHandler(internalErrorHandler))

	log.Println("Starting server on :8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatalf("Could not start server: %s\n", err)
	}
}

To run this:

  1. Save the code as main.go.
  2. Run go mod init errorserver && go mod tidy.
  3. Run go run ..

Now, test the endpoints with curl:

  • Success: curl -i http://localhost:8080/health -> 200 OK
  • Success: curl -i "http://localhost:8080/users?id=123" -> 200 OK
  • Expected API Error: curl -i "http://localhost:8080/users?id=456" -> 404 Not Found with a JSON body.
  • Unexpected Internal Error: curl -i http://localhost:8080/internal -> 500 Internal Server Error with a generic JSON body. Check your server logs to see the detailed error message.

Conclusion

Congratulations! You have implemented a robust, centralized error handling system for your Go web service. This powerful pattern will dramatically improve the quality and maintainability of your APIs.

Key Takeaways:

  • Simplify Handlers: By defining a custom handler type that returns an error, you can simplify handler logic to the idiomatic if err != nil { return err }.
  • Custom Error Types: Create custom error structs (e.g., APIError) that implement the error interface to carry extra context, like HTTP status codes and user-facing messages.
  • Centralize with a Middleware Adapter: Use a middleware adapter (ServeHTTP method on your handler type) to catch all returned errors.
  • Distinguish Error Types: Inside the middleware, use errors.As to differentiate between your custom APIErrors (for predictable client errors) and generic errors (for unexpected internal failures).
  • Respond Safely: Always return structured, safe responses to the client, while logging detailed information for developers.

Next Up

You've now built a solid foundation for handling requests, logging, authentication, and errors. In our next lesson, we'll continue building out our service's capabilities by learning how to serve static files and handle file uploads from a web client, essential skills for building more interactive applications and tools.