Follow

Follow
Golang: Handling user-friendly errors

Golang: Handling user-friendly errors

Youssef Siam's photo
Youssef Siam
·Sep 3, 2022·

4 min read

Table of contents

  • Introduction
  • Problem 1: Showing a not handled error to the user
  • Problem 2: Showing a not descriptive error to the on-call developer
  • Problem 3: The inability to take decisions based on the errors
  • Requirements
  • Solution
  • Conclusion

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

image.png

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

 
Share this