DEV Community

Lakshya Negi
Lakshya Negi

Posted on

The Ultimate Guide To Custom Error-Handling System In Go APIs

Imagine you’re using an API, and you get this response:

{
  "error": "Something went wrong."
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

Interfaces That Keep Things Flexible

The error handler leverages three critical interfaces: APIError, APIErrorCreator, and ResponseWriter.

  1. APIError: Defines how your API formats errors.
// APIError defines the interface for custom API errors.
type APIError interface {
    FromCustomError(customErr *CustomError) error
}
Enter fullscreen mode Exit fullscreen mode

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

Example:

type MyAPIErrorCreator struct{}

func (c MyAPIErrorCreator) New() APIError {
    return &MyAPIError{}
}
Enter fullscreen mode Exit fullscreen mode
  1. 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
}
Enter fullscreen mode Exit fullscreen mode

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

Complete Workflow

Here’s how all components work together in an example:

  1. Define the Error Response: Implement APIError and APIErrorCreator to structure the response.
  2. Handle Requests: Use the error handler to process errors dynamically.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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

Advantages of Code Generation

  1. Consistency: All error definitions follow the same structure and conventions.
  2. Efficiency: Eliminates repetitive coding for error structs and constructors.
  3. Scalability: Adding new errors is as simple as updating the YAML file and regenerating the code.
  4. 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,
    )
}
Enter fullscreen mode Exit fullscreen mode

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)