Imagine you’re using an API, and you get this response:
{
"error": "Something went wrong."
}
Helpful, right? (Spoiler: It’s not.)
A good error response tells you:
• What went wrong: “The resource you’re looking for doesn’t exist.”
• What to do next: “Check the URL or resource ID.”
• That the API was built with care: “Oh hey, this team really thought this through.”
That’s why I designed a system that provides consistent, actionable, and meaningful error responses. Let’s dive in!
Designing an Actionable Error Handling System
At the heart of this system is the CustomError struct. Think of it as a single, organized “package” for all the details you need to communicate an error.
CustomError Struct Breakdown
Here’s what it looks like:
type CustomError struct {
BaseErr error // Underlying Base error
StatusCode int // HTTP status code
Message string // Detailed error message
UserMessage string // User-friendly error message
ErrType string // Error type
ErrCode string // Unique error code
Retryable bool // Retryable flag
Metadata map[string]interface{} // Additional metadata
}
In plain English:
• BaseErr: The raw, underlying error.
• StatusCode: HTTP status codes like 404 (Not Found) or 500 (Internal Server Error).
• UserMessage: The “don’t scare the user” version of the error message.
• ErrType & ErrCode: Help with error categorization (handy when debugging).
• Retryable: Indicates whether it’s worth trying the request again.
Creating Your Own Errors
The system includes a factory function to simplify the creation of CustomError instances. This ensures all errors are initialized with a consistent structure:
func New(statusCode int, message, userMessage, errType, errCode string, retryable bool) *CustomError {
return &CustomError{
BaseErr: fmt.Errorf("error: %s", message),
StatusCode: statusCode,
Message: message,
UserMessage: userMessage,
ErrType: errType,
ErrCode: errCode,
Retryable: retryable,
Metadata: make(map[string]interface{}),
}
}
Let’s make this more concrete. Here’s how you can create a “Resource Not Found” error using the CustomError:
func NewNotFoundError(resource string) *CustomError {
return New(
404,
fmt.Sprintf("%s not found", resource),
"The requested resource could not be found.",
"NOT_FOUND",
"ERR_NOT_FOUND",
false,
)
}
This makes sure that any “not found” error returns a consistent, meaningful response.
While the New function is ideal for creating errors from scratch, there are cases where you may need to wrap an existing error into a CustomError. For this purpose, the NewFromError function is provided.
The NewFromError function takes an existing error to create a CustomError:
func NewFromError(err error, statusCode int, userMessage, errType, errCode string, retryable bool) *CustomError {
return &CustomError{
BaseErr: err,
StatusCode: statusCode,
Message: err.Error(),
UserMessage: userMessage,
ErrType: errType,
ErrCode: errCode,
Retryable: retryable,
Metadata: make(map[string]interface{}),
}
}
Sometimes, you’re working with functions that already return errors—like parsing a file:
func parseFile(filename string) error {
// Simulating a parsing error
return fmt.Errorf("failed to parse file: %s", filename)
}
You don’t want to lose that original error message. Here’s how you wrap it in a CustomError:
func HandleParseError(filename string) *CustomError {
err := parseFile(filename)
if err != nil {
return NewFromError(
err,
400,
"Unable to process the file you uploaded.",
"BAD_REQUEST",
"ERR_FILE_PARSE",
false,
)
}
return nil
}
This approach keeps the original error intact for debugging while providing a user-friendly message.
The Error Handler: Turning Errors into Responses
Here’s where the magic happens. The NewErrHandler function is like the ultimate “translator” between internal errors and client-facing API responses.
Key Steps in NewErrHandler:
The NewErrHandler function processes errors as follows:
- Check for CustomError: Determines if the error can be mapped to a CustomError.
- Create an API-Specific Error: Uses the APIErrorCreator interface to dynamically create an API-specific error response struct.
- Transform the Error: Maps the CustomError fields into the API-specific error using the FromCustomError method of the APIError interface.
- Handle Fallbacks: If the error transformation fails or the error isn’t a CustomError, the handler defaults to returning an internal server error.
Here’s the core logic:
func NewErrHandler(creator APIErrorCreator, writerFactory func() ResponseWriter) func(error) {
return func(werr error) {
var customErr *CustomError
writer := writerFactory()
// Check if the error is a CustomError
if errors.As(werr, &customErr) {
apiErr := creator.New()
if err := apiErr.FromCustomError(customErr); err != nil {
_ = writer.WriteResponse(http.StatusInternalServerError, NewParseErrorError(werr, err))
return
}
// Write the transformed error response
_ = writer.WriteResponse(customErr.StatusCode, apiErr)
return
}
// Handle generic errors
_ = writer.WriteResponse(http.StatusInternalServerError, NewInternalServerErrorError())
}
}
Interfaces That Keep Things Flexible
The error handler leverages three critical interfaces: APIError, APIErrorCreator, and ResponseWriter.
- APIError: Defines how your API formats errors.
// APIError defines the interface for custom API errors.
type APIError interface {
FromCustomError(customErr *CustomError) error
}
Example:
type MyAPIError struct {
StatusCode int `json:"status_code"`
Message string `json:"message"`
UserMessage string `json:"user_message"`
TraceID string `json:"trace_id"`
}
func (e *MyAPIError) FromCustomError(customErr *CustomError) error {
e.StatusCode = customErr.StatusCode
e.Message = customErr.Message
e.UserMessage = customErr.UserMessage
e.TraceID = "generated-trace-id" // can be added from the metadata also
return nil
}
- APIErrorCreator: Responsible for creating new APIError instances. Ensures fresh instances of APIError are made for each error, supporting concurrent requests.
// APIErrorCreator defines the interface for creating custom API errors.
type APIErrorCreator interface {
New() APIError
}
Example:
type MyAPIErrorCreator struct{}
func (c MyAPIErrorCreator) New() APIError {
return &MyAPIError{}
}
- ResponseWriter: Abstracts the response-writing mechanism, making the system framework-agnostic and decoupling the error-handling logic from specific frameworks like net/http, Echo, or Gin.
// ResponseWriter abstracts response writing for different frameworks.
type ResponseWriter interface {
WriteResponse(statusCode int, body interface{}) error
}
Example:
type MyResponseWriter struct {
writer http.ResponseWriter
}
func (rw MyResponseWriter) WriteResponse(statusCode int, body interface{}) error {
rw.writer.WriteHeader(statusCode)
return json.NewEncoder(rw.writer).Encode(body)
}
Complete Workflow
Here’s how all components work together in an example:
- Define the Error Response: Implement APIError and APIErrorCreator to structure the response.
- Handle Requests: Use the error handler to process errors dynamically.
- Write Responses: Use the ResponseWriter to send consistent error responses to clients.
This design allows developers to define custom error response structures and formats. The same handler logic works across multiple modules with different APIErrorCreator implementations. We can easily extend the system to handle new types of errors or integrate with new frameworks.
Error Generation Using YAML And Templates
Defining errors manually for every scenario can be a pain. That’s where code generation comes in. Instead of writing each error from scratch, I use YAML configurations and Go templates to automate the process.
The YAML Configuration
Errors are defined in a YAML file that outlines their attributes, such as the HTTP status code, error message, and other arguments.
Here’s an example:
errors:
- name: BadRequest
description: The request is invalid or malformed.
err_type: BAD_REQUEST
err_code: ERR_BAD_REQUEST
err_msg: Invalid request: %s
display_msg: The request is invalid.
status_code: 400
retryable: false
args:
- name: details
arg_type: string
- name: NotFound
description: The requested resource could not be found.
err_type: NOT_FOUND
err_code: ERR_NOT_FOUND
err_msg: %s not found
display_msg: The requested resource could not be found.
status_code: 404
retryable: false
args:
- name: resource
arg_type: string
The Go Template
The YAML configuration is transformed into Go code using a template. The template generates error structs, constructors, and helper methods for each error.
Here’s the core structure of the template:
// Code generated by error-gen. DO NOT EDIT.
package generr
import (
"apierr"
"fmt"
)
{{range .Errors}}
// {{.Name}}Error represents {{.Description}}.
type {{.Name}}Error struct {
apierr.CustomError
{{- range .Args}}
{{.Name}} {{.ArgType}} // {{.Name}} is a custom argument for the error
{{- end}}
}
// New{{.Name}}Error creates a new {{.Name}}Error.
func New{{.Name}}Error(
{{- range .Args}}{{.Name}} {{.ArgType}},{{- end}}
) *apierr.CustomError {
return apierr.New(
{{.StatusCode}},
fmt.Sprintf(
"{{.ErrMsg}}",
{{range .Args}}{{.Name}},{{end}}
),
"{{.DisplayMsg}}",
"{{.ErrType}}",
"{{.ErrCode}}",
{{if .Retryable}}true{{else}}false{{end}},
)
}
{{end}}
Generated Code Example
For the BadRequest and NotFound errors defined earlier, the following Go code would be generated:
// Code generated by error-gen. DO NOT EDIT.
package generr
import (
"apierr"
"fmt"
)
// BadRequestError represents The request is invalid or malformed.
type BadRequestError struct {
apierr.CustomError
Details string // Details is a custom argument for the error
}
// NewBadRequestError creates a new BadRequestError.
func NewBadRequestError(details string) *apierr.CustomError {
return apierr.New(
400,
fmt.Sprintf("Invalid request: %s", details),
"The request is invalid.",
"BAD_REQUEST",
"ERR_BAD_REQUEST",
false,
)
}
// NotFoundError represents The requested resource could not be found.
type NotFoundError struct {
apierr.CustomError
Resource string // Resource is a custom argument for the error
}
// NewNotFoundError creates a new NotFoundError.
func NewNotFoundError(resource string) *apierr.CustomError {
return apierr.New(
404,
fmt.Sprintf("%s not found", resource),
"The requested resource could not be found.",
"NOT_FOUND",
"ERR_NOT_FOUND",
false,
)
}
Advantages of Code Generation
- Consistency: All error definitions follow the same structure and conventions.
- Efficiency: Eliminates repetitive coding for error structs and constructors.
- Scalability: Adding new errors is as simple as updating the YAML file and regenerating the code.
- Customizability: Supports application-specific fields and error attributes through the args property.
Predefined Errors
Predefined errors are generated dynamically from the YAML configuration using the error generation mechanism. These errors provide a consistent and reusable way to represent common API issues, such as bad requests, unauthorized access, or resource not found.
Putting It All Together
The strength of this error-handling system lies in how its components—CustomError, error handlers, interfaces, and predefined errors—work together seamlessly to provide a robust and extensible solution.
Here’s an example of the workflow:
// Creator implements apierr.APIErrorCreator for custom API errors.
type Creator struct{}
func (c *Creator) New() apierr.APIError {
return NewAPIError()
}
func NewCreator() apierr.APIErrorCreator {
return &Creator{}
}
// APIError represents a custom API error structure.
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// Parse maps a CustomError to the APIError structure.
func (e *APIError) FromCustomError(err *apierr.CustomError) error {
e.Code = err.StatusCode
e.Message = err.UserMessage
return nil
}
func NewAPIError() apierr.APIError {
return &APIError{}
}
func main() {
// Generate error definitions from YAML (if required)
err := apierr.Generate("example/errors.yml", "example/errors.gen.go")
if err != nil {
log.Fatal(err)
return
}
// Initialize Echo
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Create a generic error handler
creator := NewCreator()
e.HTTPErrorHandler = func(err error, c echo.Context) {
handler := apierr.NewErrHandler(creator, func() apierr.ResponseWriter {
return &EchoResponseWriter{ctx: c}
})
handler(err)
}
// Routes to test various error scenarios
e.GET("/simple", func(c echo.Context) error {
return errors.New("simple error")
})
e.GET("/error", func(c echo.Context) error {
return apierr.NewFromError(
errors.New("complex error"),
http.StatusUnauthorized,
"Unauthorized",
"AUTH_ERROR",
"AUTH_401",
false,
)
})
e.GET("/new", func(c echo.Context) error {
return simulateError()
})
e.GET("/wrap", func(c echo.Context) error {
return fmt.Errorf("wrap: %w", simulateError())
})
e.GET("/double", func(c echo.Context) error {
return fmt.Errorf(
"outer error: %w",
fmt.Errorf("inner error: %w", simulateError()))
})
e.Logger.Fatal(e.Start(":8080"))
}
// EchoResponseWriter adapts Echo's context to the apierr.ResponseWriter interface.
type EchoResponseWriter struct {
ctx echo.Context
}
func (w *EchoResponseWriter) WriteResponse(statusCode int, body interface{}) error {
return w.ctx.JSON(statusCode, body)
}
// Simulate an error for testing purposes.
func simulateError() error {
return apierr.New(
http.StatusNotFound,
"Resource not found",
"The requested resource does not exist",
"NOT_FOUND",
"NF_404",
false,
)
}
Final Thoughts
Building this system transformed how I handle errors in my Go APIs. No more vague logs or generic “server error” responses—just clear, actionable, and developer-friendly feedback.
It might seem like a lot of work upfront, but once you’ve set it up, you’ll wonder how you ever lived without it. And the next time an error pops up, you’ll have everything you need to diagnose and fix it—without breaking a sweat.
Ready to give it a try? You’ve got this!
You can check out the full project HERE
Top comments (0)