Introduction
Let's face the fact, that error handling in Go is not familiar and has a different paradigm than any other language.
“Go programmers think about the failure case first. We solve the ‘what if ...’ case first. This leads to programs where failures are handled at the point of writing, rather than the point they occur in production. The verbosity of
if err != nil { return err }
is outweighed by the value of deliberately handling each failure condition at the point at which it occurs. Key to this is the cultural value of handling each and every error explicitly.” — Dave Cheney
and handling each failure condition at the point at which it occurs came with costs, and handling errors using an unorganized pattern can lead to a lot of mess, let's discuss...
Problem 1: Showing a not handled error to the user
I think every Go developer has faced a SQL error in the JSON response before
{ "error": "ERROR: Invalid SQL statement" }
That's embracing for you as a backend developer, right?
Problem 2: Showing a not descriptive error to the on-call developer
You may try to solve the first problem by just constructing every error yourself like
func (r repo) Get() (User, error) { var user User if err := db.Where("name = ?", "Youssef").First(&user).Error; err != nil { return User{}, errors.New("Cannot find user with name: Youssef") } return user, nil }
That's great! now every error shown to the user will be very descriptive. But we have tiny problem here, we will log this error message at the end because we have no error to log other than this. So, the logs here will not be descriptive for the developer who will troubleshoot a problem in the production at 2:00 AM. The best way to log debuggable logs is to log a message that describes what happened wrong in the code and a full stack trace.
hint: You should only handle errors once, don't log and return the same error
func Write(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { // don't do this log.Println("unable to write:", err) // and this at the same time return err } return nil }
Problem 3: The inability to take decisions based on the errors
With a complicated call stack and a lot of logic implemented and just one error returned, how can I decide whether I should return which response status?
func GET(w http.ResponseWriter, r *http.Request) {
requestByte, err := ioutil.ReadAll(r.Body)
if err != nil {
// some error handling
}
err = repository.Get(r.Context(), req)
if err != nil {
w.WriteHeader("Hmmmm?") // http.StatusNotFound or http.StatusBadRequest?
w.Write([]byte(fmt.Sprintf(`{ "error" : %q}`, err.Error())))
return
}
w.WriteHeader(http.StatusOK)
}
Requirements
With those problems, let's set a set of requirements to be our driver in solving them.
- Some sort of helper function to call before returning any error to the user to prevent us from mistakenly returning unhandled errors to the user
- Separating the errors to be logged (with stack trace) from the errors to be shown to the user without returning two errors from each function.
- A way to make decisions based on the error types in a clean way without messing up our code.
Solution
We need to unify a pattern to use in the whole project, let's say some sort of tweaked errors package.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"errors"
"github.com/gorilla/mux"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type Product struct {
gorm.Model
Code string `json:"code"`
Price uint `json:"price"`
}
var db *gorm.DB
func main() {
var err error
db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&Product{})
// Create
db.Create(&Product{Code: "D42", Price: 100})
r := mux.NewRouter()
r.HandleFunc("/product/{id}", GET)
http.ListenAndServe(":8090", r)
}
func GetByID(context context.Context, id string) (Product, error) {
var product Product
err := db.Last(&product, id).Error
if err != nil {
return product, errors.NotFoundErr(err, "Cannot find a product with this id.")
}
return product, err
}
func GET(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, ok := vars["id"]
if !ok {
fmt.Println("id is missing in parameters")
}
p, err := GetByID(r.Context(), id)
if err != nil {
status, msg := errors.HTTPStatusCodeMessage(err)
log.Printf("Error: %+v", err)
w.WriteHeader(status)
w.Write([]byte(fmt.Sprintf(`{ "error" : %q}`, msg)))
return
}
bytes, err := json.Marshal(p)
if err != nil {
// no need to handle this error as it occured on the same level
log.Printf("Error: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
w.Write(bytes)
return
}
JSON Response
{ "error" : "Cannot find a product with this id."}
Console
Conclusion
We can make the unfamiliar nature of error handling in Go to play on our side by using the flexibility of Golang's types and polymorphism and tweak the errors to follow our needs.
You can find the mentioned errors package implementation here: github.com/bnkamalesh/errors