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.

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:
- The standard
net/httpserver doesn't know how to work with a handler that returns anerror. - A simple
errordoesn'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.
- The Adapter: A function that takes our custom
AppHandlerand returns a standardhttp.HandlerFunc. This adapter will execute our handler, capture the returned error, and be responsible for writing the final HTTP response. - Custom Error Type: A struct that implements the
errorinterface 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:
- Execute the application handler.
- If the returned
errorisnil, the request was successful. Do nothing. - If an
erroris returned, useerrors.Asto check if it can be treated as our*APIError. - If it is an
*APIError, use itsStatusandMessageto write a structured JSON response. - If it's not an
*APIError, it's an unexpected error. Log the full error message for debugging and return a generic500 Internal Server Errorto 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 genericerrorrepresents an unexpected failure. - Security: A generic
errorfrom a lower-level package (likedatabase/sqloros) 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 a404 Not Foundif 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:
- Save the code as
main.go. - Run
go mod init errorserver && go mod tidy. - 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 Foundwith a JSON body. - Unexpected Internal Error:
curl -i http://localhost:8080/internal->500 Internal Server Errorwith 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 idiomaticif err != nil { return err }. - Custom Error Types: Create custom error structs (e.g.,
APIError) that implement theerrorinterface to carry extra context, like HTTP status codes and user-facing messages. - Centralize with a Middleware Adapter: Use a middleware adapter (
ServeHTTPmethod on your handler type) to catch all returned errors. - Distinguish Error Types: Inside the middleware, use
errors.Asto differentiate between your customAPIErrors (for predictable client errors) and genericerrors (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.