Conquering Errors in Go: A Guide to Returning and Handling errors

A Beginner’s Guide to mastering Go Error Handling

DK
Ride

--

source: https://medium.com/codex/things-i-dislike-about-go-4c245a74ee17

Level 1: if err != nil

This is the most simple method of returning error. Most of us are familiar with this pattern. You call a function that might return an error, check if the error is nil, if it isn’t return the error

import (
"errors"
"fmt"
)

func doSomething() (float64, error) {
result, err := mayReturnError();
if err != nil {
return 0, err
}
return result, nil
}

Problems with this approach

While this maybe the simplest and infact the most used method it suffers from a major problem: lack of context. If you have a deep call stack you don’t know which function caused the error.

Imagine you have a call stack where function A() calls B(), B() calls C() and C() returns an error which looks like this:

package main

import (
"errors"
"fmt"
)

func A(x int) (int, error) {
result, err := B(x)
if err != nil {
return 0, err
}
return result * 3, nil
}

func B(x int) (int, error) {
result, err := C(x)
if err != nil {
return 0, err
}
return result + 2, nil
}

func C(x int) (int, error) {
if x < 0 {
return 0, errors.New("negative value not allowed")
}
return x * x, nil
}

func main() {
// Call function A with invalid input
result, err := A(-2)
if err == nil {
fmt.Println("Result:", result)
} else {
fmt.Println("Error:", err)
}
}

If you run this program it will output the following

Error: negative value not allowed

We have no context in the error message itself as to where this error occured in the call stack. We have to open the program in code editor and search for the specific error string to find where the error could have been originating from.

Level 2: Wrapped Errors

To add context to the errors we will wrap the errors using fmt.Errorf .

package main

import (
"errors"
"fmt"
)

func A(x int) (int, error) {
result, err := B(x)
if err != nil {
return 0, fmt.Errorf("A: %w", err)
}
return result * 3, nil
}

func B(x int) (int, error) {
result, err := C(x)
if err != nil {
return 0, fmt.Errorf("B: %w", err)
}
return result + 2, nil
}

func C(x int) (int, error) {
if x < 0 {
return 0, fmt.Errorf("C: %w", errors.New("negative value not allowed"))
}
return x * x, nil
}

func main() {
// Call function A with invalid input
result, err := A(-2)
if err == nil {
fmt.Println("Result:", result)
} else {
fmt.Println("Error:", err)
}
}

if we run this program we will get the following output

Error: A: B: C: negative value not allowed

Now we understand the call stack.

However it still has a problem.

Problems with this approach

We now know where the error has occurred but we still don’t know what has gone wrong.

Level 3: Descriptive Errors

The error isn’t descriptive enough. To demonstrate this we need a slightly more complex example.

import (
"errors"
"fmt"
)

func DoSomething() (int, error) {
result, err := DoSomethingElseWithTwoSteps()
if err != nil {
return 0, fmt.Errorf("DoSomething: %w", err)
}
return result * 3, nil
}

func DoSomethingElseWithTwoSteps() (int, error) {
stepOne, err := StepOne()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps:%w", err)
}

stepTwo, err := StepTwo()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: %w", err)
}

return stepOne + StepTwo, nil
}

In this example, if an error is returned we don’t know which operation in particular has failed, StepOne or StepTwo. We will get the same error saying Error: DoSomething: DoSomethingElseWithTwoSteps: UnderlyingError

To fix that we need to add context of what specifically has gone wrong

import (
"errors"
"fmt"
)

func DoSomething() (int, error) {
result, err := DoSomethingElseWithTwoSteps()
if err != nil {
return 0, fmt.Errorf("DoSomething: %w", err)
}
return result * 3, nil
}

func DoSomethingElseWithTwoSteps() (int, error) {
stepOne, err := StepOne()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: StepOne: %w", err)
}

stepTwo, err := StepTwo()
if err != nil {
return 0, fmt.Errorf("DoSomethingElseWithTwoSteps: StepTwo: %w", err)
}

return stepOne + StepTwo, nil
}

So now if StepOne fails we will get Error: DoSomething: DoSomethingElseWithTwoSteps: StepOne failed: UnderlyingError

Problems with this approach

The error now expresses the call stack using function names. But it does not express the nature of the error. Errors should tell a story.

A good example is HTTP status code. If you receive a 404 you know the resource you were trying to get doesn’t exist.

Level 4: Error Sentinels

Error sentinels are predefined error constants that can be reused.

There can be various causes a function can fail but I like to broadly put it into 4 categories. Not Found Error, Already Exists Error, Failed Precondition error and Internal Error. These are inspired by gRPC status codes. Let me explain each category in one sentence.

Not Found Error: The resource the caller wants does not exist. Example: A deleted Article.

Already Exists Error: The resource the caller wants to create, already exists. Example: An organisation with the same name.

Failed Precondition Error: The operation caller wants to execute does not meet the conditions to execute or is in a bad state. Example: Trying to debit an account with 0 balance.

Internal Error: Any other error which does not fall into these categories is an Internal Error.

Just having these types of errors is not enough. You have to let the caller know which kind of error it is. We achieve this using error sentinels and errors.Is.

Imagine you have a REST API where people can fetch and update their wallet balance. Let’s see how we can use error sentinels when fetching wallet from db.

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

// These are error sentinels
var (
ErrWalletDoesNotExist = errors.New("Wallet does not exist") //Type of Not Found Error
ErrCouldNotGetWallet = errors.New("Could not get Wallet") //Type of Internal Error
)

func getWalletFromDB(id int) (int, error) {
// Dummy implementation: simulate retrieving a wallet from a database
balance, err := db.get(id)

if err != nil {
if balance == nil {
return 0, fmt.Errorf("%w: Wallet(id:%s) does not exist: %w", ErrWalletDoesNotExist, id, err)
} else {
return 0, return fmt.Errorf("%w: could not get Wallet(id:%s) from db: %w", ErrCouldNotGetWallet, id, err)
}
}

return *balance, nil
}

What makes sentinels REALLY useful is that now the REST handler can do the following

func getWalletBalance() {
wallet, err := getWalletFromDB(id)

if errors.Is(err, ErrWalletDoesNotExist) {
// return 404
} else if errors.Is(err, ErrCouldNotGetWallet) {
// return 500
}
}

Let’s see another example where the user wants to update the balance

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

var (
ErrWalletDoesNotExist = errors.New("Wallet does not exist") //Type of Not Found Error
ErrCouldNotDebitWallet = errors.New("Could not debit Wallet") //Type of Internal Error
ErrInsiffucientWalletBalance = errors.New("Insufficient balance in Wallet") //Type of Failed Precondition Error
)

func debitWalletInDB(id int, amount int) error {
// Dummy implementation: simulate retrieving a wallet from a database
balance, err := db.get(id)

if err != nil {
if balance == nil {
return fmt.Errorf("%w: Wallet(id:%s) does not exist: %w", ErrWalletDoesNotExist, id, err)
} else {
return fmt.Errorf("%w: could not get Wallet(id:%s) from db: %w", ErrCouldNotDebitWallet, id, err)
}
}

if *balance <= 0 {
return 0, fmt.Errorf("%w: Wallet(id:%s) balance is 0", ErrInsiffucientWalletBalance, id)
}

updatedBalance := *balance - amount

// Dummy implementation: simulate updating a wallet into a database
err := db.update(id, updatedBalance)

if err != nil {
return fmt.Errorf("%w: could not update Wallet(id:%s) from db: %w", ErrCouldNotDebitWallet, id, err)
}

return nil
}

Writing better error messages using sentinels

You may have already seen I like to format errors in a specific way. I like to structure an error message in one of two ways

  • fmt.Errorf("%w: description: %w", Sentinel, err) or
  • fmt.Errorf("%w: description", Sentinel)

This makes sure the error tells a story. The story of What went wrong, Why and the underlying cause.

This is important because as you can see in the above example a same type of error can be caused by two different underlying issues. So description helps us pinpoint exactly what went wrong and why.

Bonus: Where to Log errors

You may be surprised that you should not log every error you find. Why? you do you end up with logs that look like this

Error: C: negative value not allowed
Error: B: C: negative value not allowed
Error: A: B: C: negative value not allowed

Rather you should only log errors where you “Handle” errors. By handling errors I mean where the caller upon receiving the error can do something with it and continue executing instead of just returning the error.

A prime example would be, again, a REST handler. If a REST handler receives an error it can look at the type of the error and, send appropriate response with status code and stop the propagation of error.

func getWalletBalance() {
wallet, err := getWalletFromDB(id)

if err != nil {
fmt.Printf("%w", err) //Log the error only here
}

if errors.Is(err, ErrWalletDoesNotExist) {
// return 404
} else if errors.Is(err, ErrCouldNotGetWallet) {
// return 500
}
}

--

--