Skip to content

Error handling in the node using std::error_code and std::expected

cryptocode edited this page Jul 2, 2018 · 11 revisions

Overview

The node has traditionally used bool to communicate the result of a function, where false indicates success. This is in line with the convention of zero indicating success for integral error codes in C/C++.

C++11 introduced std::error_code which has several advantages over bool return codes:

  • They convey the precise reason for a failure.
  • The error codes are type safe enums. Switching over class-enums without a default case doesn't compile without exhaustive matching. For instance, this means that forgetting to associate an error message with an error code leads to a compile error
  • All error codes have a descriptive message. Instead of repeating error messages several places, you define it once. This enforces consistent error reporting and simplifies refactoring.

C++20 will likely standardize std::expected, a proposed mechanism for returning Rust- and Haskell style "either" types, i.e. either a value or an error code. The node includes an implementation which will be removed once the compilers implements the standard.

  • std::expected works great with std::error_code. Both can be used standalone or in combination.
  • Return values works like optional, but with error codes.
  • Efficiently implemented, values and errors share the same memory space.

Defining error codes

Common error codes

The file errors.hpp lists all the common error codes, which are shared by all Node files. If you add a new common error code, also update error.cpp file with a descriptive message (you will get a compile error if you forget)

Specific error codes

An error enum can be defined for any file or class. For instance, the RPC file contains an error enum at the top of the header file, making the error codes available to all RPC-related classes.

In other cases, it may make sense to associate error codes with a class. These error codes will only be available to this specific class.

Adding new error codes

There's a bit of setup needed to use error codes in a new module/file/class. This is a one-time job, after which adding new error codes is trivial.

hpp file

The first step is to to include the error header:

#include <rai/lib/errors.hpp>

Next, define your error codes. In this example, we're adding RPC-related errors. The convention is to put the error enum in the rai namespace, and prefix it with error_<module name>. This makes the use-site syntax more readable.

namespace rai
{
    enum class error_rpc
    {
        // Every error enum must have this first entry. You'll get a compile error
        // if you forget. This makes error codes in bool contexts work properly.
	generic =  1,
	control_disabled,
        ...
    };
}

The final step in the header is to register the error codes. Put this line at the very end of the header file:

REGISTER_ERROR_CODES (rai, error_rpc)

The first argument is the namespace, the second is the enum name.

You can have multiple error code enums per file, of course.

Using this convenience macro isn't strictly necessary, but it saves you from writing a lot of boiler plate code for each error code enum.

In short, the macro registers the error code enum in the std namespace. This is one of few times where it's okay (and required) to do this. It also defines a make_error_code overload for our enum (for automatic conversion from enum to error_code), as well as a std::error_category declaration.

Note: If you break the convention of using rai as the namespace for the error enum, you'll have to implement the boilerplate manually.

cpp file

To associate error messages with our error codes, we need to implement message:

std::string rai::error_rpc_messages::message (int ev) const
{
    // Typesafe exchaustive switch
    switch (static_cast<rai::error_rpc> (ev))
    {
        case rai::error_rpc::generic:
            return "Unknown error";
        case rai::error_rpc::control_disabled:
            return "RPC control is disabled";
    }
}

Note that error_rpc_messages is automatically declared for you by REGISTER_ERROR_CODES - you only need to implement the message function.

Using std:error_code

Here's an example that uses both common and rpc specific error codes:

void rai::rpc_handler::account_create ()
{
    std::error_code ec;
    if (rpc.config.enable_control)
    {
        auto error (wallet.decode_hex (wallet_text));
        if (!error)
        {
            auto existing (node.wallets.items.find (wallet));
            if (existing != node.wallets.items.end ())
            {
                // All good
            }
            else
            {
                ec = error_common::wallet_not_found;
            }
        }
        else
        {
            ec = error_common::bad_wallet_number;
        }
    }
    else
    {
        // Just to show that you can call make_error_code explicitly
        ec = make_error_code (error_rpc::control_disabled);
    }

    // If there's an error, send an error response
    check_error (response, ec);
}

By convention, error_code variables are named ec or ec_l in local variables, and ec_a when passed as parameters.

Notice how we're not repeating error strings, but rather uses the message associated with the error code. This ensures that error messages are consistent across the system.

Compare error codes

If you know the error category, just compare against the enum value:

if (ec == error_common::bad_account_number)
{
    ...
}

Compare error categories

Important: Per C++11 spec, the error codes may not be unique across error categories. If a function may return errors from multiple categories, check the category explicitly:

std::error_code ec = process ();

// Is it a block_store error?
if (error.category() == rai::error_blockstore_category())
{
    // Check for specific blockstore error codes if needed
}

Note that error_blockstore_category () is generated automatically for you by REGISTER_ERROR_CODES.

Using std::expected

Sometimes you need to return either a value or an error code. This is what std::expected is for.

Below is an example of using std::expected with std::error_code.

We return either an account_info object or a std::error_code

expected<rai::account_info, std::error_code> rai::block_store::account_get (...)
{
    expected<rai::account_info, std::error_code> res;
    
    if (something_bad)
    {
        res = unexpected_error (error::missing_account);
    }
    else
    {
        rai::account_info info (node->get_account_info (...));
        res = info;
    }
    
    return res;
}

Note that unexpected_error (x) is a short-hand for make_unexpected (make_error_code (x))

Let's call account_get which returns either an account or an error:

auto info (store.account_get (transaction, pending_key.account));
if (info)
{
    // We definitely have an account. We use the account_info fields via the arrow operator
    check_account (info->rep_block);
}
else
{
    // Conveniently, the error has a message attached to it.
    status->setText ("Could not query account information: " + info.error ().message ()));

    // And of course error codes
    if (info.error () == error_common::missing_account)
    {
        // Do something special
    }
}

An example using string and error_code:

// We normally use 'auto' for the expected type, but
// sometimes it makes sense to write it out explicitly:
expected<std::string, std::error_code> val = do_something ();

if (val)
{
    std::cerr << "OK: " << val.value () << std::endl;
}
else
{
    std::cerr << "ERROR MESSAGE: " << val.error ().message () << std::endl;
}

Finally, just to show that we don't have to use std::error_code with expected:

expected<std::string, int> val = make_unexpected (12);
if (val)
{
    std::cerr << "OK: " << val.value () << std::endl;
}
else
{
    std::cerr << "INTEGER ERROR:" << val.error() << std::endl;
}