Building a Robust Error Handling System for Go APIs
Wenhao Wang
Dev Intern · Leapcell

Introduction
In the world of networked applications, particularly microservices and APIs, graceful error handling isn't just a best practice—it's a critical component of a reliable and user-friendly system. When things go wrong, an opaque or cryptic error message can frustrate users, mislead downstream services, and complicate debugging efforts for developers. Conversely, a well-defined and structured error system allows for clear communication of problems, consistent logging for operational insights, and predictable behavior across different API endpoints. This article delves into designing such a system in Go, focusing on how to effectively manage errors for both API responses and internal logging, ensuring our applications are not only functional but also resilient and observable.
Core Concepts for Structured Errors
Before diving into the implementation, let's define some key terms integral to our structured error handling approach:
- Error Code: A unique identifier, typically a string or enumeration, representing a specific type of error within the system. This provides a standardized way to machine-read and classify errors, independent of their human-readable message.
- Error Category/Type: A broader classification for errors (e.g.,
Validation Error
,Authentication Error
,Internal Server Error
). This helps group similar error codes and can influence how an error is processed or presented. - User Message: A human-readable message intended for the end-user or client application, explaining what went wrong in an understandable, non-technical way. This message might differ for different locales.
- Developer Message: A more detailed, technical message intended for developers during debugging. This might include internal details, context, and potential remediation steps.
- Error Context: Additional, dynamic key-value pairs that provide specific contextual information about the error. For example, for a validation error, this might include the field that failed validation and the invalid value. For a database error, it might include the query attempted.
- HTTP Status Code: The standard numerical code indicating the outcome of an HTTP request (e.g.,
200 OK
,400 Bad Request
,500 Internal Server Error
). While related to errors, our internal error structure will drive the choice of HTTP status code, rather than being the error itself.
Designing a Structured Error System in Go
Our goal is to create a custom error type in Go that encapsulates all these pieces of information. This will allow us to propagate rich error details throughout our application and then translate them appropriately for API responses and log entries.
The Custom Error Type
Let's define a custom error struct:
package apperror import ( "fmt" "net/http" ) // Category defines the broad type of error. type Category string const ( CategoryBadRequest Category = "BAD_REQUEST" CategoryUnauthorized Category = "UNAUTHORIZED" CategoryForbidden Category = "FORBIDDEN" CategoryNotFound Category = "NOT_FOUND" CategoryConflict Category = "CONFLICT" CategoryInternal Category = "INTERNAL_SERVER_ERROR" CategoryServiceUnavailable Category = "SERVICE_UNAVAILABLE" // Add more categories as needed ) // Error represents a structured application error. type Error struct { Code string `json:"code"` // A unique identifier for the error (e.g., "USER_NOT_FOUND") Category Category `json:"category"` // Broad category of the error (e.g., "NOT_FOUND") UserMessage string `json:"user_message"` // User-friendly message DevMessage string `json:"dev_message,omitempty"` // Developer-friendly message, optional Context map[string]interface{} `json:"context,omitempty"` // Additional key-value pairs for context Cause error `json:"-"` // The underlying error, not serialized } // Error implements the error interface. func (e *Error) Error() string { if e.DevMessage != "" { return fmt.Sprintf("[%s:%s] %s (Dev: %s)", e.Category, e.Code, e.UserMessage, e.DevMessage) } return fmt.Sprintf("[%s:%s] %s", e.Category, e.Code, e.UserMessage) } // Unwrap allows errors.Is and errors.As to work with our custom error type. func (e *Error) Unwrap() error { return e.Cause } // New creates a new structured error. func New(category Category, code, userMsg string, opts ...ErrorOption) *Error { err := &Error{ Category: category, Code: code, UserMessage: userMsg, Context: make(map[string]interface{}), // Initialize context to avoid nil map panics } for _, opt := range opts { opt(err) } return err } // ErrorOption defines a functional option for customizing errors. type ErrorOption func(*Error) // WithDevMessage sets the developer message. func WithDevMessage(msg string) ErrorOption { return func(e *Error) { e.DevMessage = msg } } // WithContext adds a key-value pair to the error context. func WithContext(key string, value interface{}) ErrorOption { return func(e *Error) { e.Context[key] = value } } // WithCause sets the underlying cause of the error. func WithCause(cause error) ErrorOption { return func(e *Error) { e.Cause = cause } } // MapCategoryToHTTPStatus maps an error category to a standard HTTP status code. func MapCategoryToHTTPStatus(cat Category) int { switch cat { case CategoryBadRequest: return http.StatusBadRequest case CategoryUnauthorized: return http.StatusUnauthorized case CategoryForbidden: return http.StatusForbidden case CategoryNotFound: return http.StatusNotFound case CategoryConflict: return http.StatusConflict case CategoryServiceUnavailable: return http.StatusServiceUnavailable case CategoryInternal: return http.StatusInternalServerError default: return http.StatusInternalServerError // Default to internal server error for unhandled categories } }
This Error
struct implements the error
interface, allowing it to be used wherever a standard Go error
is expected. The Unwrap
method is crucial for compatibility with Go's errors
package functions (errors.Is
, errors.As
). We also provide functional options to build errors concisely.
Exemplary Usage in Application Logic
Now, let's see how we'd use this in a service layer:
package userservice import ( "errors" "fmt" "your_module/apperror" // Assuming apperror package is defined above ) // User represents a user entity. type User struct { ID string Name string Email string } // UserRepository defines an interface for user data access. type UserRepository interface { GetUserByID(id string) (*User, error) CreateUser(user *User) error } // Service provides user-related business logic. type Service struct { repo UserRepository } func NewService(repo UserRepository) *Service { return &Service{repo: repo} } // GetUser fetches a user by ID. func (s *Service) GetUser(id string) (*User, error) { user, err := s.repo.GetUserByID(id) if err != nil { if errors.Is(err, apperror.New(apperror.CategoryNotFound, "USER_NOT_FOUND", "User not found")) { // This check is a simplification; ideally, the repository would return our structured error. // For demonstration, let's assume repo returns a generic error for now. } // Example: Assuming repo returns a generic error indicating not found if err.Error() == "sql: no rows in result set" { // Or some other specific error from underlying driver return nil, apperror.New( apperror.CategoryNotFound, "USER_NOT_FOUND", "The requested user could not be found.", apperror.WithDevMessage(fmt.Sprintf("User with ID %s does not exist in the database.", id)), apperror.WithContext("userID", id), apperror.WithCause(err), // Wrap the underlying database error ) } // For other unexpected repository errors return nil, apperror.New( apperror.CategoryInternal, "DB_OPERATION_FAILED", "An unexpected error occurred while fetching user data.", apperror.WithDevMessage(fmt.Sprintf("Failed to retrieve user ID %s from the database.", id)), apperror.WithContext("operation", "GetUserByID"), apperror.WithCause(err), ) } return user, nil } // CreateUser creates a new user. func (s *Service) CreateUser(user *User) error { if user.Name == "" || user.Email == "" { return apperror.New( apperror.CategoryBadRequest, "INVALID_USER_DATA", "User name and email are required.", apperror.WithContext("input", user), ) } err := s.repo.CreateUser(user) if err != nil { // Example: Assuming a unique constraint violation from the database if errors.Is(err, apperror.New(apperror.CategoryConflict, "DUPLICATE_EMAIL", "Email already in use")) { // Again, this check is a simplification. Ideally, the repo returns our structured error. } if err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" { return apperror.New( apperror.CategoryConflict, "DUPLICATE_EMAIL", "The provided email is already registered.", apperror.WithDevMessage(fmt.Sprintf("Email '%s' already exists.", user.Email)), apperror.WithContext("email", user.Email), apperror.WithCause(err), ) } return apperror.New( apperror.CategoryInternal, "DB_INSERT_FAILED", "An unexpected error occurred while creating user.", apperror.WithDevMessage(fmt.Sprintf("Failed to insert user '%s' into the database.", user.Email)), apperror.WithCause(err), ) } return nil }
API Response Handling
An HTTP handler would then receive these structured errors and translate them into appropriate API responses.
package httpapi import ( "encoding/json" "net/http" "your_module/apperror" "your_module/userservice" // Assuming userservice package ) // ErrorResponse defines the structure for API error responses. type ErrorResponse struct { Code string `json:"code"` Category apperror.Category `json:"category"` Message string `json:"message"` Details map[string]interface{} `json:"details,omitempty"` // Renamed from Context for client-facing } // UserHandler handles user-related HTTP requests. type UserHandler struct { service *userservice.Service } func NewUserHandler(service *userservice.Service) *UserHandler { return &UserHandler{service: service} } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { userID := r.URL.Query().Get("id") if userID == "" { h.writeError(w, apperror.New( apperror.CategoryBadRequest, "MISSING_USER_ID", "User ID is required.", apperror.WithContext("param", "id"), )) return } user, err := h.service.GetUser(userID) if err != nil { h.writeError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(user) } func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var newUser userservice.User if err := json.NewDecoder(r.Body).Decode(&newUser); err != nil { h.writeError(w, apperror.New( apperror.CategoryBadRequest, "INVALID_JSON_BODY", "Request body is not valid JSON.", apperror.WithCause(err), )) return } err := h.service.CreateUser(&newUser) if err != nil { h.writeError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newUser) // Or a success message } func (h *UserHandler) writeError(w http.ResponseWriter, err error) { var appErr *apperror.Error if !errors.As(err, &appErr) { // This is an unexpected, unstructured error. Log it thoroughly. // For the API response, we'll return a generic internal server error. appErr = apperror.New( apperror.CategoryInternal, "UNEXPECTED_ERROR", "An unexpected internal error occurred.", apperror.WithDevMessage(err.Error()), // Capture the original message for dev apperror.WithCause(err), ) } logError(appErr) // Our centralized logging function httpStatus := apperror.MapCategoryToHTTPStatus(appErr.Category) resp := ErrorResponse{ Code: appErr.Code, Category: appErr.Category, Message: appErr.UserMessage, Details: appErr.Context, // Use Context as Details for client presentation } w.Header().Set("Content-Type", "application/json") w.WriteHeader(httpStatus) json.NewEncoder(w).Encode(resp) }
Structured Logging
For logging, we can leverage the rich context embedded in our apperror.Error
struct.
package httpapi // Or a dedicated logging package import ( "log/slog" // Go 1.21+ structured logging "your_module/apperror" ) // Our custom logger, possibly wrapped around slog. // This is a simplified example; a real-world logger would be more configurable. var logger = slog.Default() // logError processes a structured application error for logging. func logError(err error) { var appErr *apperror.Error if !errors.As(err, &appErr) { // Log truly unexpected, unstructured errors logger.Error("Unhandled error encountered", "error", err) return } logAttrs := []slog.Attr{ slog.String("error_code", appErr.Code), slog.String("error_category", string(appErr.Category)), slog.String("user_message", appErr.UserMessage), } if appErr.DevMessage != "" { logAttrs = append(logAttrs, slog.String("developer_message", appErr.DevMessage)) } // Add context fields for k, v := range appErr.Context { logAttrs = append(logAttrs, slog.Any(k, v)) } // Log the underlying cause if present if appErr.Cause != nil { logAttrs = append(logAttrs, slog.Any("cause", appErr.Cause.Error())) // Log the cause's message } // Determine log level based on error category logLevel := slog.LevelError if appErr.Category == apperror.CategoryBadRequest || appErr.Category == apperror.CategoryNotFound || appErr.Category == apperror.CategoryConflict { // Client-side errors might be logged as Info or Warn, depending on policy logLevel = slog.LevelWarn } logger.LogAttrs(r.Context(), logLevel, "Application error", logAttrs...) }
This logging function ensures that all relevant details of our structured error are captured as key-value pairs in the log, making it easy to filter, search, and analyze errors using tools like ELK stack, Splunk, or cloud logging services. Client-side errors might be logged at a WARNING
level, while server-side errors typically warrant an ERROR
level, providing clearer operational insights.
Benefits of this Approach
- Consistency: All errors across the API have a uniform structure, simplifying client-side error parsing and handling.
- Clarity: Separate messages for users and developers ensure both audiences receive appropriate information.
- Traceability: Error codes and categories provide quick identification.
Context
makes debugging specific instances much easier. - Observability: Structured logs are machine-parseable, improving monitoring, alerting, and analysis of error trends.
- Maintainability: Easier to add new error types, categorize them, and manage error responses centrally.
- Decoupling: The internal error representation is independent of the external HTTP status code, allowing for flexible mapping.
Conclusion
A well-designed error handling system is paramount for building robust and maintainable Go API applications. By encapsulating error details into a custom, structured error type, we can achieve consistent API responses, detailed and machine-readable logs, and a significantly improved developer experience. This approach transforms error handling from a mere necessity into a powerful tool for application reliability and observability. A structured error system ensures that when things go wrong, we gain clarity, not confusion.