Skip to main content

Echo Error Handling

Error handling is a critical aspect of building reliable and user-friendly APIs. When developing with the Echo framework, understanding how to properly catch, process, and respond to errors ensures your application remains stable under various conditions and provides meaningful feedback to clients.

Introduction to Error Handling in Echo

In any web application, things can go wrong - database connections might fail, input might be invalid, or resources might be unavailable. Without proper error handling, these situations can lead to application crashes, unhelpful error messages, or even security vulnerabilities.

Echo provides several mechanisms for handling errors across your application, allowing you to:

  • Centralize error handling with custom error handlers
  • Return consistent error responses to clients
  • Log errors appropriately for debugging
  • Handle panics to prevent application crashes

Understanding Echo's Default Error Handler

Echo comes with a built-in default error handler which responds with a JSON object when errors occur:

go
// Default error response format
{
"message": "error message"
}

When an error is returned from a handler, Echo automatically passes it to the default error handler which:

  1. Logs the error
  2. Sends an appropriate HTTP status code
  3. Returns a JSON response with the error message

Here's a simple example showing how errors flow in an Echo application:

go
package main

import (
"errors"
"net/http"

"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()

e.GET("/error-demo", func(c echo.Context) error {
// Simulate an error
return errors.New("something went wrong")
})

e.Start(":8080")
}

When accessing /error-demo, Echo's default error handler will respond with:

  • HTTP Status: 500 Internal Server Error
  • Response Body: {"message":"something went wrong"}

HTTP Error Handling in Echo

For more control over error responses, Echo provides the echo.NewHTTPError function to create HTTP errors with specific status codes:

go
package main

import (
"net/http"

"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()

e.GET("/users/:id", func(c echo.Context) error {
// Get user ID from the URL parameter
id := c.Param("id")

// Simulate a user not found scenario
if id == "999" {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}

// Simulate an unauthorized access scenario
if id == "admin" {
return echo.NewHTTPError(http.StatusForbidden, "Access denied")
}

// Normal response
return c.JSON(http.StatusOK, map[string]string{
"user_id": id,
"name": "Sample User",
})
})

e.Start(":8080")
}

Access the endpoint with different IDs to see the different error responses:

  • /users/999 will return a 404 error with "User not found" message
  • /users/admin will return a 403 error with "Access denied" message
  • /users/123 will return a successful response with user data

Creating a Custom Error Handler

Echo allows you to override the default error handler with your own implementation to provide more detailed error responses or handle errors differently based on their type.

Here's how to implement a custom error handler:

go
package main

import (
"fmt"
"net/http"

"github.com/labstack/echo/v4"
)

// Custom error response format
type ErrorResponse struct {
Status int `json:"status"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}

func main() {
e := echo.New()

// Custom error handler
e.HTTPErrorHandler = func(err error, c echo.Context) {
code := http.StatusInternalServerError
message := "Internal Server Error"
details := err.Error()

// Check if the error is an Echo HTTP error
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = fmt.Sprintf("%v", he.Message)
}

// Don't expose error details in production
if e.Debug {
c.JSON(code, ErrorResponse{
Status: code,
Message: message,
Details: details,
})
} else {
c.JSON(code, ErrorResponse{
Status: code,
Message: message,
})
}

// Log the error
e.Logger.Error(err)
}

// Handler with error
e.GET("/error", func(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request")
})

e.Start(":8080")
}

In this example, we've created a custom error handler that:

  1. Checks if the error is an Echo HTTP error to extract the status code and message
  2. Creates a consistent error response structure
  3. Only includes detailed error information in debug mode
  4. Logs all errors

Handling Validation Errors

A common use case for error handling is form validation. Echo works well with Go validation libraries like go-playground/validator:

go
package main

import (
"net/http"

"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)

// User represents a user registration data
type User struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=18,lte=120"`
Password string `json:"password" validate:"required,min=8"`
}

// ValidationError represents a field validation error
type ValidationError struct {
Field string `json:"field"`
Rule string `json:"rule"`
Value string `json:"value,omitempty"`
}

// CustomValidator handles validation of structs
type CustomValidator struct {
validator *validator.Validate
}

// Validate validates a struct
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}

func main() {
e := echo.New()

// Initialize validator
e.Validator = &CustomValidator{validator: validator.New()}

// Custom error handler for validation errors
e.HTTPErrorHandler = func(err error, c echo.Context) {
if validationErrors, ok := err.(validator.ValidationErrors); ok {
// Handle validation errors
errors := make([]ValidationError, 0)

for _, err := range validationErrors {
errors = append(errors, ValidationError{
Field: err.Field(),
Rule: err.Tag(),
Value: err.Param(),
})
}

c.JSON(http.StatusBadRequest, map[string]interface{}{
"status": http.StatusBadRequest,
"message": "Validation failed",
"errors": errors,
})

return
}

// Handle other errors
code := http.StatusInternalServerError
message := "Internal Server Error"

if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = he.Message.(string)
}

c.JSON(code, map[string]interface{}{
"status": code,
"message": message,
})
}

// Register user endpoint
e.POST("/register", func(c echo.Context) error {
u := new(User)

if err := c.Bind(u); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request payload")
}

if err := c.Validate(u); err != nil {
return err // This will be caught by the custom error handler
}

// Process user registration...

return c.JSON(http.StatusCreated, map[string]string{
"message": "User registered successfully",
})
})

e.Start(":8080")
}

When sending an invalid request like:

json
{
"name": "Jo",
"email": "not-an-email",
"age": 15,
"password": "short"
}

The API would respond with:

json
{
"status": 400,
"message": "Validation failed",
"errors": [
{
"field": "Name",
"rule": "min",
"value": "3"
},
{
"field": "Email",
"rule": "email"
},
{
"field": "Age",
"rule": "gte",
"value": "18"
},
{
"field": "Password",
"rule": "min",
"value": "8"
}
]
}

Panic Recovery Middleware

When a panic occurs in your application, the server typically crashes completely. Echo provides a built-in recovery middleware that catches panics and converts them into errors that can be handled by the error handler:

go
package main

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
e := echo.New()

// Add recovery middleware
e.Use(middleware.Recover())

// Handler that will panic
e.GET("/panic", func(c echo.Context) error {
// Simulate a panic
names := []string{"Alice"}
return c.String(http.StatusOK, names[10]) // Will panic: index out of range
})

e.Start(":8080")
}

With the recovery middleware in place, accessing /panic won't crash your server. Instead, it will:

  1. Catch the panic
  2. Convert it to an error
  3. Pass it to the error handler
  4. Log the stack trace
  5. Return a 500 Internal Server Error to the client

Best Practices for Error Handling

When implementing error handling in your Echo applications, consider these best practices:

  1. Use appropriate status codes: Match your HTTP status codes to the error type:

    • 400 Bad Request: For invalid input
    • 401 Unauthorized: For authentication failures
    • 403 Forbidden: For authorization failures
    • 404 Not Found: For missing resources
    • 422 Unprocessable Entity: For validation errors
    • 500 Internal Server Error: For server-side errors
  2. Provide meaningful error messages: Error messages should be clear and actionable for API consumers.

  3. Log detailed errors: Include detailed information in logs for debugging, but don't expose sensitive details to clients.

  4. Implement consistent error structure: Use a consistent JSON structure for all error responses.

  5. Add request identifiers: Include a unique identifier with each request to correlate logs with specific API calls:

go
e.Use(middleware.RequestID())

e.HTTPErrorHandler = func(err error, c echo.Context) {
// ... error handling code

// Include request ID in response
errorResponse["request_id"] = c.Response().Header().Get(echo.HeaderXRequestID)

c.JSON(code, errorResponse)
}

Real-world Example: API with Multiple Error Types

Let's combine everything we've learned into a more complete API example:

go
package main

import (
"errors"
"fmt"
"net/http"
"runtime"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

// Custom error types
var (
ErrResourceNotFound = errors.New("resource not found")
ErrDatabaseError = errors.New("database error")
ErrUnauthorized = errors.New("unauthorized access")
ErrBadInput = errors.New("invalid input")
)

// ErrorResponse defines the structure of error responses
type ErrorResponse struct {
Status int `json:"status"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
Details interface{} `json:"details,omitempty"`
}

func main() {
e := echo.New()

// Middlewares
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.RequestID())

// Custom error handler
e.HTTPErrorHandler = customErrorHandler

// Routes
e.GET("/users/:id", getUserHandler)
e.POST("/users", createUserHandler)
e.GET("/protected", protectedHandler)
e.GET("/panic-test", panicTestHandler)

e.Logger.Fatal(e.Start(":8080"))
}

func customErrorHandler(err error, c echo.Context) {
var (
code = http.StatusInternalServerError
message = "Internal Server Error"
details interface{}
)

// Extract request ID
requestID := c.Response().Header().Get(echo.HeaderXRequestID)

// Handle different error types
switch {
case errors.Is(err, ErrResourceNotFound):
code = http.StatusNotFound
message = "Resource not found"

case errors.Is(err, ErrUnauthorized):
code = http.StatusUnauthorized
message = "Authentication required"

case errors.Is(err, ErrBadInput):
code = http.StatusBadRequest
message = "Invalid input data"

case errors.Is(err, ErrDatabaseError):
// Keep 500 status but customize message
message = "Service temporarily unavailable"
}

// Check if it's an Echo HTTP error
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = fmt.Sprintf("%v", he.Message)

if he.Internal != nil {
details = fmt.Sprintf("%v", he.Internal)
}
}

// Create error response
resp := ErrorResponse{
Status: code,
Message: message,
RequestID: requestID,
}

// Only include error details in development
if e.Debug {
resp.Details = details

// For 500 errors, include the error message in details
if code >= 500 && details == nil {
resp.Details = err.Error()
}
}

// Log the error with stack trace for 5xx errors
if code >= 500 {
stack := make([]byte, 4096)
length := runtime.Stack(stack, false)
e.Logger.Errorf("[%s] %v: %s\n", requestID, err, stack[:length])
} else {
e.Logger.Infof("[%s] %v", requestID, err)
}

// Send response
c.JSON(code, resp)
}

// Handlers for demonstration
func getUserHandler(c echo.Context) error {
id := c.Param("id")

if id == "999" {
return ErrResourceNotFound
}

// Simulate database error
if id == "error" {
return fmt.Errorf("%w: connection refused", ErrDatabaseError)
}

return c.JSON(http.StatusOK, map[string]string{
"id": id,
"name": "Sample User",
})
}

func createUserHandler(c echo.Context) error {
// Simulate validation error
return echo.NewHTTPError(http.StatusUnprocessableEntity, "Email address is invalid")
}

func protectedHandler(c echo.Context) error {
token := c.Request().Header.Get("Authorization")

if token != "valid-token" {
return ErrUnauthorized
}

return c.String(http.StatusOK, "Protected data")
}

func panicTestHandler(c echo.Context) error {
// Simulate a panic
var nullMap map[string]string
nullMap["key"] = "value" // This will panic

return c.NoContent(http.StatusOK)
}

Summary

Effective error handling is crucial for building robust Echo APIs. In this guide, we've covered:

  1. Echo's default error handling mechanism
  2. Creating custom HTTP errors with status codes
  3. Implementing a custom error handler
  4. Handling validation errors
  5. Using recovery middleware to catch panics
  6. Best practices for API error handling
  7. A comprehensive example with different error types

By implementing proper error handling in your Echo applications, you'll provide a better experience for API consumers, make debugging easier, and build more resilient services.

Additional Resources

Exercises

  1. Modify the custom error handler to format errors according to the JSON API specification.
  2. Implement rate limiting in your Echo application and handle rate limit exceeded errors.
  3. Create a middleware that adds context information (like user agent, IP address) to error logs.
  4. Extend the validation example to handle nested struct validation errors.
  5. Build an Echo API that connects to a database and properly handles database connection errors.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)