Error handling

👨‍🏫 Before we start…

  • Go error values usually provide a more detailed context about what actually went wrong. However, returning the actual error messages to end user mostly cause confusion and make the system vulnerable to security threats. So, when an error occurs, we will send a custom but meaningful error message to the end-user.
  • We choose the go-playground/validator for form validations, as it supports struct level validations, extensive validation rules, customizable error handling, etc.

Adding error messages to the API

1. Identify and standardize errors

While an error occurs, returning the actual error details to the end-user could result in a poor user/ developer experience and could potentially expose sensitive information and posing a security risk. Let’s identify the error cases and standardize our error messages.

Errors can be occurred HTTP code Custom error for response
While inserting data into the database 500 db data insert failure
While accessing data from the database 500 db data access failure
While updating data in the database 500 db data update failure
While removing data in the database 500 db data remove failure
While encoding json to generate the response 500 json encode failure
While decoding json to read data from create/ update forms 500 json decode failure
While decoding book ID from URL parameters as a valid UUID 400 invalid url param - id
While validating forms 422 *array of error messages

👨‍🏫 In the next article, we’ll add structured logging capabilities and log the actual error for debugging and auditing purposes.

2. Define helper functions

Let’s add the above error messages to api/resource/common/err/err.go with helper functions.

var (
	RespDBDataInsertFailure = []byte(`{"error": "db data insert failure"}`)
	RespDBDataAccessFailure = []byte(`{"error": "db data access failure"}`)
	RespDBDataUpdateFailure = []byte(`{"error": "db data update failure"}`)
	RespDBDataRemoveFailure = []byte(`{"error": "db data remove failure"}`)

	RespJSONEncodeFailure = []byte(`{"error": "json encode failure"}`)
	RespJSONDecodeFailure = []byte(`{"error": "json decode failure"}`)

	RespInvalidURLParamID = []byte(`{"error": "invalid url param-id"}`)
)

func ServerError(w http.ResponseWriter, reps []byte) {
	w.WriteHeader(http.StatusInternalServerError)
	w.Write(reps)
}

func BadRequest(w http.ResponseWriter, reps []byte) {
	w.WriteHeader(http.StatusBadRequest)
	w.Write(reps)
}

func ValidationErrors(w http.ResponseWriter, reps []byte) {
	w.WriteHeader(http.StatusUnprocessableEntity)
	w.Write(reps)
}

3. Update API handlers

Let’s update the handlers in api/resource/book/handler.go.

import (
	e "myapp/api/resource/common/err"
)

func (a *API) List(w http.ResponseWriter, r *http.Request) {
	books, err := a.repository.List()
	if err != nil {
		e.ServerError(w, e.RespDBDataAccessFailure)
		return
	}

	if len(books) == 0 {
		fmt.Fprint(w, "[]")
		return
	}

	if err := json.NewEncoder(w).Encode(books.ToDto()); err != nil {
		e.ServerError(w, e.RespJSONEncodeFailure)
		return
	}
}

func (a *API) Create(w http.ResponseWriter, r *http.Request) {
	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		e.ServerError(w, e.RespJSONDecodeFailure)
		return
	}

	newBook := form.ToModel()
	newBook.ID = uuid.New()

	_, err := a.repository.Create(newBook)
	if err != nil {
		e.ServerError(w, e.RespDBDataInsertFailure)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

func (a *API) Read(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		e.BadRequest(w, e.RespInvalidURLParamID)
		return
	}

	book, err := a.repository.Read(id)
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			w.WriteHeader(http.StatusNotFound)
			return
		}

		e.ServerError(w, e.RespDBDataAccessFailure)
		return
	}

	dto := book.ToDto()
	if err := json.NewEncoder(w).Encode(dto); err != nil {
		e.ServerError(w, e.RespJSONEncodeFailure)
		return
	}
}

func (a *API) Update(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		e.BadRequest(w, e.RespInvalidURLParamID)
		return
	}

	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		e.ServerError(w, e.RespJSONDecodeFailure)
		return
	}

	book := form.ToModel()
	book.ID = id

	rows, err := a.repository.Update(book)
	if err != nil {
		e.ServerError(w, e.RespDBDataUpdateFailure)
		return
	}
	if rows == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
}

func (a *API) Delete(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		e.BadRequest(w, e.RespInvalidURLParamID)
		return
	}

	rows, err := a.repository.Delete(id)
	if err != nil {
		e.BadRequest(w, e.RespDBDataRemoveFailure)
		return
	}
	if rows == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
}

With this, our custom error messages should appear while an error occurs.

Adding validator to the API

In here, we add go-playground/validator, set struct level validations to the create/ update book form, add custom validator with own custom validation rule alphaspace and add it to the API.

1. Add go-playground/validator

go get github.com/go-playground/validator/v10

2. Set validation tags

In here, we use their built-in required, datetime, max, url validate tags and our own custom alphaspace validation. Let’s update the form in api/resource/book/model.go

type Form struct {
	Title         string `json:"title" validate:"required,max=255"`
	Author        string `json:"author" validate:"required,alphaspace,max=255"`
	PublishedDate string `json:"published_date" validate:"required,datetime=2006-01-02"`
	ImageURL      string `json:"image_url" validate:"url"`
	Description   string `json:"description"`
}

🔍 On the go-playground/validator README file, you can see the built-in validation rules they provide.

3. Add util/validator/validator.go

We combine their struct-level and custom validation example implementations.

package validator

import (
	"reflect"
	"regexp"
	"strings"

	"github.com/go-playground/validator/v10"
)

const alphaSpaceRegexString string = "^[a-zA-Z ]+$"

func New() *validator.Validate {
	validate := validator.New()
	validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
		name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
		if name == "-" {
			return ""
		}
		return name
	})

	validate.RegisterValidation("alphaspace", isAlphaSpace)

	return validate
}

func isAlphaSpace(fl validator.FieldLevel) bool {
	reg := regexp.MustCompile(alphaSpaceRegexString)
	return reg.MatchString(fl.Field().String())
}

RegisterTagNameFunc helps to set the error message form field name according to the json tag, instead of using capitalized struct field name. isAlphaSpace is the custom validation rule to validate the fields with only alphabetic characters and spaces.

4. Add validator as an API dependency

import "github.com/go-playground/validator/v10"

type API struct {
	repository *Repository
	validator  *validator.Validate
}

func New(db *gorm.DB, v *validator.Validate) *API {
	return &API{
		repository: NewRepository(db),
		validator:  v,
	}
}

5. Update router

import "github.com/go-playground/validator/v10"

func New(db *gorm.DB, v *validator.Validate) *chi.Mux {
	r := chi.NewRouter()

	r.Get("/livez", health.Read)

	r.Route("/v1", func(r chi.Router) {
		bookAPI := book.New(db, v)

6. Update cmd/api/main.go

import validatorUil "myapp/util/validator"

func main() {
	c := config.New()
	v := validatorUil.New()

	var logLevel gormlogger.LogLevel
	if c.DB.Debug {
		logLevel = gormlogger.Info
	} else {
		logLevel = gormlogger.Error
	}

	dbString := fmt.Sprintf(fmtDBString, c.DB.Host, c.DB.Username, c.DB.Password, c.DB.DBName, c.DB.Port)
	db, err := gorm.Open(postgres.Open(dbString), &gorm.Config{Logger: gormlogger.Default.LogMode(logLevel)})
	if err != nil {
		log.Fatal("DB connection start failure")
		return
	}

	r := router.New(db, v)

7. Run go mod tidy

When we add a new package and use it, we have to run go mod tidy to reorganize the dependencies in the go.mod file.

Using validator from handlers

Let’s check the error of validator.Validate Struct() via the API.validator.Struct()

func (a *API) Create(w http.ResponseWriter, r *http.Request) {
	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		e.ServerError(w, e.RespJSONDecodeFailure)
		return
	}

	if err := a.validator.Struct(form); err != nil {
		fmt.Println(err)
		return
	}

	newBook := form.ToModel()
	newBook.ID = uuid.New()

	_, err := a.repository.Create(newBook)
	if err != nil {
		e.ServerError(w, e.RespDBDataInsertFailure)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

It returns an array of FieldError’s.

Key: 'Form.title' Error:Field validation for 'title' failed on the 'required' tag
Key: 'Form.author' Error:Field validation for 'author' failed on the 'required' tag
Key: 'Form.published_date' Error:Field validation for 'published_date' failed on the 'required' tag
Key: 'Form.image_url' Error:Field validation for 'image_url' failed on the 'url' tag

go-playground/validator comes with go-playground/locales andgo-playground/universal-translator packages. In here, we use our own much simpler approach to support custom error messages and generate json error response.

1. Add util/validator/response.go

package validator

import (
	"fmt"

	"github.com/go-playground/validator/v10"
)

type ErrResponse struct {
	Errors []string `json:"errors"`
}

func ToErrResponse(err error) *ErrResponse {
	if fieldErrors, ok := err.(validator.ValidationErrors); ok {
		resp := ErrResponse{
			Errors: make([]string, len(fieldErrors)),
		}

		for i, err := range fieldErrors {
			switch err.Tag() {
			case "required":
				resp.Errors[i] = fmt.Sprintf("%s is a required field", err.Field())
			case "max":
				resp.Errors[i] = fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param())
			case "url":
				resp.Errors[i] = fmt.Sprintf("%s must be a valid URL", err.Field())
			case "alphaspace":
				resp.Errors[i] = fmt.Sprintf("%s can only contain alphabetic and space characters", err.Field())
			case "datetime":
				if err.Param() == "2006-01-02" {
					resp.Errors[i] = fmt.Sprintf("%s must be a valid date", err.Field())
				} else {
					resp.Errors[i] = fmt.Sprintf("%s must follow %s format", err.Field(), err.Param())
				}
			default:
				resp.Errors[i] = fmt.Sprintf("something wrong on %s; %s", err.Field(), err.Tag())
			}
		}

		return &resp
	}

	return nil
}

The ToErrResponse() function helps to convert the array of FieldError’s returns from go-playground/validator into an ErrResponse struct, which we can be used to generate the JSON error response. For example,

{
	"errors": [
		"title is a required field",
		"author is a required field",
		"published_date is a required field",
		"image_url must be a valid URL"
	]
}

2. Update handlers

import validatorUtil "myapp/util/validator"

func (a *API) Create(w http.ResponseWriter, r *http.Request) {
	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		e.ServerError(w, e.RespJSONDecodeFailure)
		return
	}

	if err := a.validator.Struct(form); err != nil {
		respBody, err := json.Marshal(validatorUtil.ToErrResponse(err))
		if err != nil {
			e.ServerError(w, e.RespJSONEncodeFailure)
			return
		}

		e.ValidationErrors(w, respBody)
		return
	}

	newBook := form.ToModel()
	newBook.ID = uuid.New()

	_, err := a.repository.Create(newBook)
	if err != nil {
		e.ServerError(w, e.RespDBDataInsertFailure)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

func (a *API) Update(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		e.BadRequest(w, e.RespInvalidURLParamID)
		return
	}

	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		e.ServerError(w, e.RespJSONDecodeFailure)
		return
	}

	if err := a.validator.Struct(form); err != nil {
		respBody, err := json.Marshal(validatorUtil.ToErrResponse(err))
		if err != nil {
			e.ServerError(w, e.RespJSONEncodeFailure)
			return
		}

		e.ValidationErrors(w, respBody)
		return
	}

	book := form.ToModel()
	book.ID = id

	rows, err := a.repository.Update(book)
	if err != nil {
		e.ServerError(w, e.RespDBDataUpdateFailure)
		return
	}
	if rows == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
}

📁 Final project structure

myapp
├── cmd
│  ├── api
│  │  └── main.go
│  └── migrate
│     └── main.go
├── api
│  ├── router
│  │  └── router.go
│  │
│  └── resource
│     ├── health
│     │  └── handler.go
│     ├── book
│     │  ├── handler.go
│     │  ├── model.go
│     │  ├── repository.go
│     │  └── repository_test.go
│     └── common
│        └── err
│           └── err.go
├── migrations
│  └── 00001_create_books_table.sql
├── config
│  └── config.go
├── .env
├── go.mod
├── go.sum
├── mock
│  └── db
│     └── db.go
├── util
│  ├── test
│  │  └── test.go
│  └── validator
│     └── validator.go
│     └── response.go
├── docker-compose.yml
└── Dockerfile

👨‍🏫 What’s next…

In the next article, we’ll add the request logs, error logs and the logger to our application.