cover

How to Correctly Handle Errors in Go Echo

sorcererxw

labstack/echo is a widely used Web framework within the Go ecosystem. Compared to other well-known frameworks, one of the features of echo is that it throws out errors through return values on the handler, rather than passing errors using context:

e.GET("/ping", func(c echo.Context) error {
	err := doSomthing()
	if err != nil {
		return err
	}
	return c.NoContent(http.StatusOK)
})

Under the Go programming paradigm of throwing errors with return values, this method is undoubtedly more convenient and natural. However, in many cases, an error is not a program error, but a user input error. If error handling is not controlled, all errors will be considered as HTTP/500 (internal errors). Therefore, whether the error thrown by the handler can be correctly handled determines whether the final response can meet expectations.

To handle errors correctly in Echo, it's first necessary to understand what an error in Echo goes through:

// MiddlewareFunc defines a function to process middleware.
MiddlewareFunc func(HandlerFunc) HandlerFunc

// HandlerFunc defines a function to serve HTTP requests.
HandlerFunc func(Context) error

// HTTPErrorHandler is a centralized HTTP error handler.
HTTPErrorHandler func(error, Context)

func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
	for i := len(middleware) - 1; i >= 0; i-- {
		h = middleware[i](h)
	}
	return h
}

// ServeHTTP implements `http.Handler` interface, which serves HTTP requests.
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  var c *echo.Context
  e.findRouter(r.Host).Find(r.Method, GetPath(r), c)
	h = c.Handler()
	h = applyMiddleware(h, e.middleware...)

	if err := h(c); err != nil {
		e.HTTPErrorHandler(err, c)
	}
}

The above code demonstrates the core workflow of echo handling requests. As can be seen, outside the onion model of middleware-handler, echo uses HTTPErrorHandler to handle the final errors. As stated in the echo documentation:

Echo advocates for centralized HTTP error handling by returning error from middleware and handlers. Centralized error handler allows us to log errors to external services from a unified location and send a customized HTTP response to the client.

This mechanism provides a fallback solution for all errors, ensuring that all errors can be effectively handled.

Some people might be wondering: Why do we need to single out an Error Handler? Why not just write an Error Handle Middleware and wrap it around the entire onion ring:

e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
		err := next(c)
	  return handleError(err, c)
	}
})

Indeed, this way can also handle errors, even more concisely, but echo's HTTPErrorHandler is not just about handling the final error simply. It can be called at any time on the entire call chain through the Error function of echo.Context!

// Error invokes the registered HTTP error handler. Generally used by middleware.
func (c *context) Error(err error) {
	c.echo.HTTPErrorHandler(err, c)
}

e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
		err := next(c)
		if err!=nil {
      c.Error(err)
    }
    return
	}
})

But why do we need to do this? Here we need to take a look at what HTTPErrorHandler actually does. HTTPErrorHandler itself is just a function type that can be customized by the user, but echo provides a DefaultHTTPErrorHandler by default. Let's use it as a reference:

// DefaultHTTPErrorHandler is the default HTTP error handler. It sends a JSON response
// with status code.
func (e *Echo) DefaultHTTPErrorHandler(err error, c Context) {
	he, ok := err.(*HTTPError)
	if !ok {
		he = &HTTPError{
			Code:    http.StatusInternalServerError,
			Message: http.StatusText(http.StatusInternalServerError),
		}
	}

	// Send response
	if !c.Response().Committed {
		err = c.JSON(he.Code, map[string]string{"message":he.message})
		if err != nil {
			e.Logger.Error(err)
		}
	}
}

From the source code, we can see that DefaultHTTPErrorHandler writes the corresponding response directly based on the specific type of error. As we all know, the http response will be closed after it is written, and after manually calling context.Error, the subsequent middleware will not be able to change the response body.

Returning to the main question, why do we need to manually call context.Error? Because only after truly invoking HTTPErrorHandler, can the final response status be determined. Often times, service monitoring requires recording the final response status. If errors are thrown all the way up, there will be no chance to get the final status. Therefore, when encountering an error, it is necessary to actively execute context.Error.

// LoggerWithConfig returns a Logger middleware with config.
// See: `Logger()`.
func LoggerWithConfig(config LoggerConfig) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) (err error) {
			req := c.Request()
			res := c.Response()
			if err = next(c); err != nil {
				c.Error(err)
			}
			if _, err = config.template.ExecuteFunc(buf, func(w io.Writer, tag string) (int, error) {
				switch tag {
				case "status":
					n := res.Status
					s := config.colorer.Green(n)
					switch {
					case n >= 500:
						s = config.colorer.Red(n)
					case n >= 400:
						s = config.colorer.Yellow(n)
					case n >= 300:
						s = config.colorer.Cyan(n)
					}
					return buf.WriteString(s)
				}
				return 0, nil
			}); err != nil {
				return
			}
		}
	}
}

The above is the Logger middleware provided by Echo. As you can see, if Logger gets an error, it will determine the final response through c.Error and read the response status code. It then returns a nil error at the end to prevent the next level middleware from processing the same error again.

It is precisely because some middleware have to execute context.Error that the error cannot continue to be passed down, and the response is closed, extra attention needs to be paid to the order when using middleware. Based on my experience, middleware can be divided into two types:

  • Care about errors

    This kind of middleware needs to handle the error itself, such as converting specific errors into HTTPError, and reporting error stacks using Sentry.

  • Care about the response

    This type of middleware needs to monitor the status of the response body. They are not interested in the error itself, but when encountering a situation where err≠nil, they will primarily call HTTPErrorHandler to determine the final response, and will eventually return a nil error. Typical examples include Prometheus and Logger.

(Of course, there are some middleware that neither care about errors nor responses, they are not within the scope of this article)

Therefore, when we configure middleware for the service, we must remember to place the configurations that care about the response on the outer layer, the configurations that care about errors on the inner layer, and the business-related ones are even further inside.