-
Notifications
You must be signed in to change notification settings - Fork 0
Error handling in the node using std::error_code and std::expected
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 withstd::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.
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)
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.
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.
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.
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.
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.
If you know the error category, just compare against the enum value:
if (ec == error_common::bad_account_number)
{
...
}
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.
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;
}