How to handle errors in the proper way

Hi there! Based on a question sent by Frank, I’m trying to show you how, based on the logic you provide, you handle errors in the proper way on your public interface and the way users are going to interact against your code. So, let’s take a look!

The requirement

We have a requirement where we need to get a user from a given database so we have a function like this:

func GetUser(id int64) (*User, error) {
    connection, err := getDbConnection()
    if err != nil {
        return nil, errors.New("database error")
    }

    user, err := connection.GetUser(id)
    if err != nil {
        return nil, errors.New("error getting user from database")
    }
    return user, nil
}

Let’s take a look at the behavior we’re providing in here:

  1. We get a connection from our DB. If we have an error, we return nil for the user and the corresponding error.
  2. We use the connection to get the user from the DB. If we have an error, return nil for the user and the error.
  3. If we don’t have any error when trying to get the user then return the user and the nil error.

This sounds good, right? Let’s test our function:

func TestGetUserDatabaseDown(t *testing.T) {
    // Mock connection DB to return an error when trying to get a new connection.

    user, err := GetUser(1)

    assert.Nil(t, user)
    assert.NotNil(t, err)
    assert.EqualValues(t, "database error", err.Error())
}

func TestGetUserErrorExecutingQuery(t *testing.T) {
    // Mock connection DB to return an error when trying to get our user.

    user, err := GetUser(1)

    assert.Nil(t, user)
    assert.NotNil(t, err)
    assert.EqualValues(t, "error getting user from database", err.Error())
}

func TestGetUserNoError(t *testing.T) {
    // Mock connection DB to return a user.

    user, err := GetUser(1)

    assert.Nil(t, err)
    assert.NotNil(t, user)
    assert.EqualValues(t, 1, user.Id)
    assert.EqualValues(t, "user@gmail.com", user.Email)
}

When executing our tests we can see that we have 100% of coverage. Awesome! Everything is working. We have tested all of the three possible scenarios: Getting a connection, using the connection for getting a user and returning what we’ve found.

The problem

Wait… What if we have a connection, we execute the query but the user we’re finding does not exists in the DB. Let’s add that test case as well.

func TestGetUserNotFound(t *testing.T) {
    // Mock connection DB to return a user not found in the DB.

    user, err := GetUser(1)

    assert.Nil(t, user)
    assert.NotNil(t, err)
    assert.EqualValues(t, "user 1 not found", err.Error())
}

Now we execute the test case and BOOM!

panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x10 pc=0x68e8d1]

What happened here?

Let’s take a look at our function:

func GetUser(id int64) (*User, error) {
    connection, err := getDB()
    if err != nil {
        return nil, errors.New("database error")
    }

    user, err := connection.GetUser(id)
    if err != nil {
        return nil, errors.New("error getting user from database")
    }
    return user, nil
}

If we don’t have any error we’re just returning user, nil BUT because of the way connection.GetUser(id) is implemented it is returning nil, nil when the record was not found. And we’re providing the same interface to our users.

The problem with this approach is that we force every user calling our GetUser function to validate if err != nil and also if user == nil for handling the not found case. So we have two options:

Solution A: Update our business logic

func GetUser(id int64) (*User, error) {
    connection, err := getDB()
    if err != nil {
        return nil, errors.New("database error")
    }

    user, err := connection.GetUser(id)
    if err != nil {
        return nil, errors.New("error getting user from database")
    }

    if user == nil {
        return nil, errors.New(fmt.Sprintf("user %d not found", id))
    }
    return user, nil
}

And the test case keeps as before:

func TestGetUserNotFound(t *testing.T) {
    // Mock connection DB to return a record not found in the DB.

    user, err := GetUser(1)

    assert.Nil(t, user)
    assert.NotNil(t, err)
    assert.EqualValues(t, "user 1 not found", err.Error())
}

Solution B: Update our test cases

func GetUser(id int64) (*User, error) {
    connection, err := getDB()
    if err != nil {
        return nil, errors.New("database error")
    }

    user, err := connection.GetUser(id)
    if err != nil {
        return nil, errors.New("error getting user from database")
    }

    return user, nil
}

And we update just the test case:

func TestGetUserNotFound(t *testing.T) {
    // Mock connection DB to return a record not found in the DB.

    user, err := GetUser(1)

    assert.Nil(t, user)
    assert.Nil(t, err)
}

Which one should you use?

It depends on the public API you want to provide. If you use ANY built-in Go library that returns an error you’ll see that they all return (value1, ...., error) so you can see if err != nil and then ignore all of the values at the left of the error or use them if err == nil, like these examples:

  1. https://golang.org/pkg/os/#Open
  2. https://golang.org/pkg/database/sql/#Open

Based on these examples, this is how you use most of the functions available in the standard library:

value1, value2, err := package.Function(params)
if err != nil {
    // Handle error and ignore all of the values since they're probably nil anyway.
    return
}

// You can safely use all of the values:

fmt.Println(value1.Field)
fmt.Println(value2.OtherField)

If you want to return nil for both your values and your error then you’re forcing all of your users to validate the nil condition for every return value you provide. So, be careful when handling errors since you could end up with a serious mess because of the usage you provide.

As a general rule, if your function returns values and an error, remember:

  1. If error is not nil, you just need to handle the error and ignore other return values.
  2. If error is nil you can safely use other return values since they won’t be nil.

If you follow this convention you’ll see how most of your nil pointers go away. Always have in mind a Go proverb saying:

Don’t just check errors, handle them gracefully.

Hope you liked the article and I’d love to hear your thoughts!

Fede.

Leave a Comment