...one of the most highly
regarded and expertly designed C++ library projects in the
world.
— Herb Sutter and Andrei
Alexandrescu, C++
Coding Standards
The previous tutorial did not include any error handling. When an error is encountered while talking to the DB or the client, an exception is thrown and the program terminates. This is undesirable in server programs like the one we're writing.
To add error handling, we can just add try/catch blocks to prevent exception
propagation. However, many code bases discourage the use of exceptions for
non-exceptional circumstances, like I/O errors. In this tutorial, we will learn
how to manage I/O errors without exceptions by using asio::as_tuple
and error codes.
There are two kind of I/O errors that our program can encounter:
async_get_connection
is cancelled because of a timeout. In this case, we will return a special
string ("ERROR"
)
to the client, signalling that we can't fulfill the request, and log the
problem.
Additionally, we will modify how we use asio::cancel_after
to make the system more reliable.
Before proceeding, we need to understand what a completion token is. The concepts in this section are not specific to Boost.MySQL, but apply to Asio and all Asio-compatible libraries. Since Asio docs can be terse, we explain them here to facilitate the reader.
All asynchronous operations accept an optional, last parameter specifying what to do when the operation completes. This last parameter is the operation's completion token.
Callbacks are valid completion tokens. Taking async_get_connection
as example, the following is valid:
// Function to call when async_get_connection completes auto on_available_connection = [](boost::system::error_code ec, mysql::pooled_connection conn) { // Do something useful with the connection }; // Start the operation. on_available_connection will be called when the operation // completes. on_available_connection is the completion token. // When a callback is passed, async_get_connection returns void, // so we can't use co_await with it. pool.async_get_connection(on_available_connection);
We have already been using this when creating coroutines. asio::co_spawn
is also an async operation, and the callback we pass as its last parameter
is the completion token.
You might consider using callbacks if your compiler doesn't support coroutines, or just by personal preference. This example demonstrates how to use them.
If you don't specify a completion token, the operation's default
completion token will be used. This is usually asio::deferred
or mysql::with_diagnostics(asio::deferred)
[1]. These tokens transform asynchronous operations into awaitables,
so we can use them in C++20 coroutines.
The default completion token for async_get_connection
is mysql::with_diagnostics(asio::deferred)
. This means that the following two are equivalent:
// These two lines are equivalent. // Both of them can be read as "I want to use C++20 coroutines as my completion style" auto conn1 = co_await pool.async_get_connection(); auto conn2 = co_await pool.async_get_connection(mysql::with_diagnostics(asio::deferred));
Completion tokens are generic: once you learn how to use one, you can use it with any Asio-compliant async operation. This includes all functions in Boost.Asio, Boost.MySQL, Boost.Beast and Boost.Redis. We say that operations in these libraries are compliant with Asio's universal async model. Writing these is hard, but they're easy to use!
Some tokens don't fully specify what to do when the operation completes, but rather modify some aspect of how the operation executes. They wrap (or adapt) other completion tokens. The underlying token determines what to do when the operation completes.
asio::cancel_after
is an adapter token. It modifies how an operation executes by setting a timeout,
but it doesn't specify what to do on completion.
Adapter tokens can be passed an optional completion token as the last argument. If the token is omitted, the default one will be used. Continuing with our example:
// Enable the use of the "s" suffix for std::chrono::seconds using namespace std::chrono_literals; // The following two lines are equivalent. // Both get a connection, waiting no more than 20s before cancelling the operation. // If no token is passed to cancel_after, the default one will be used, // which transforms the operation into an awaitable. // asio::cancel_after(20s) is usually termed "partial completion token" auto conn1 = co_await pool.async_get_connection(asio::cancel_after(20s)); auto conn2 = co_await pool.async_get_connection( asio::cancel_after(20s, mysql::with_diagnostics(asio::deferred)) );
Each async operation has an associated handler signature. We can find these signatures in the documentation for each operation. The handler signature is the prototype that a callback function passed as completion token would need to have to be compatible with the operation.
The handler signature for async_get_connection
is void(boost::system::error_code, mysql::pooled_connection)
.
However, when we invoke co_await
on the awaitable returned by async_get_connection
,
we don't get any error_code
.
This is because co_await
inspects
the handler signature at compile-time, looking for an error_code
as first parameter. If it finds it, co_await
will remove it from the argument list, returning only the pooled_connection
.
At runtime, the error code is checked. If the code indicates a failure, an
exception is thrown.
This mechanism is important to understand how as_tuple
works.
asio::as_tuple
is another adapter completion token that can be used to prevent exceptions.
It modifies the operation's handler signature, packing all arguments into a
std::tuple
. This inhibits the automatic error
code checks explained in the previous section, thus preventing exceptions on
I/O failure. Continuing with our example:
// Passing asio::as_tuple transforms the operation's handler signature: // Original: void(error_code, mysql::pooled_connection) // Transformed: void(std::tuple<error_code, mysql::pooled_connection>) // The transformed signature no longer has an error_code as first parameter, // so no automatic error code to exception transformation happens. std::tuple<boost::system::error_code, mysql::pooled_connection> res = co_await pool.async_get_connection(asio::as_tuple);
In practice, it's usually better to use structured bindings:
// ec is an error_code, conn is the mysql::pooled_connection. // If the operation fails, ec will be non-empty. auto [ec, conn] = co_await pool.async_get_connection(asio::as_tuple);
All the properties of adapter completion tokens apply:
// The following two lines are equivalent. // Both of them produce an awaitable that produces a tuple when awaited. auto [ec1, conn1] = co_await pool.async_get_connection(asio::as_tuple); auto [ec2, conn2] = co_await pool.async_get_connection( asio::as_tuple(mysql::with_diagnostics(asio::deferred)) );
Adapter tokens can be combined. To apply a timeout to the operation while avoiding exceptions, you can use:
// ec is an error_code, conn is the mysql::pooled_connection // Apply a timeout and don't throw on error auto [ec, conn] = co_await pool.async_get_connection(asio::cancel_after(20s, asio::as_tuple));
Let's apply asio::as_tuple
to our database logic. We will remove timeouts for now - we will add them back
later.
asio::awaitable<std::string> get_employee_details(mysql::connection_pool& pool, std::int64_t employee_id) { // Get a connection from the pool. // This will wait until a healthy connection is ready to be used. // ec is an error code, conn is a pooled_connection auto [ec, conn] = co_await pool.async_get_connection(asio::as_tuple); if (ec) { // A connection couldn't be obtained. // This may be because a timeout happened. log_error("Error in async_get_connection", ec); co_return "ERROR"; } // Use the connection normally to query the database. mysql::static_results<mysql::pfr_by_name<employee>> result; auto [ec2] = co_await conn->async_execute( mysql::with_params("SELECT first_name, last_name FROM employee WHERE id = {}", employee_id), result, asio::as_tuple ); if (ec2) { log_error("Error running query", ec); co_return "ERROR"; } // Handle the result as we did in the previous tutorial }
While what we wrote works, it can be improved. When a database operation fails,
the server may supply an error string with information about what went wrong.
Boost.MySQL may also generate such strings in certain cases. We get this automatically
when using exceptions. Thanks to with_diagnostics
and default completion tokens, the library throws error_with_diagnostics
objects, which inherit from boost::system::system_error
and have a get_diagnostics
member.
When using error codes, we need to handle diagnostics manually. All functions
in Boost.MySQL are overloaded to accept a diagnostics
output parameter. It will be populated with extra information in case of error.
Let's update our code to use diagnostics:
asio::awaitable<std::string> get_employee_details(mysql::connection_pool& pool, std::int64_t employee_id) { // Will be populated with error information in case of error mysql::diagnostics diag; // Get a connection from the pool. // This will wait until a healthy connection is ready to be used. // ec is an error_code, conn is the mysql::pooled_connection auto [ec, conn] = co_await pool.async_get_connection(diag, asio::as_tuple); if (ec) { // A connection couldn't be obtained. // This may be because a timeout happened. log_error("Error in async_get_connection", ec, diag); co_return "ERROR"; } // Use the connection normally to query the database. mysql::static_results<mysql::pfr_by_name<employee>> result; auto [ec2] = co_await conn->async_execute( mysql::with_params("SELECT first_name, last_name FROM employee WHERE id = {}", employee_id), result, diag, asio::as_tuple ); if (ec2) { log_error("Error running query", ec2, diag); co_return "ERROR"; } // Compose the message to be sent back to the client if (result.rows().empty()) { co_return "NOT_FOUND"; } else { const auto& emp = result.rows()[0]; co_return emp.first_name + ' ' + emp.last_name; } // When the pooled_connection is destroyed, the connection is returned // to the pool, so it can be re-used. }
We also need to write the function to log errors:
// Log an error to std::cerr void log_error(const char* header, boost::system::error_code ec, const mysql::diagnostics& diag = {}) { // Inserting the error code only prints the number and category. Add the message, too. std::cerr << header << ": " << ec << " " << ec.message(); // client_message() contains client-side generated messages that don't // contain user-input. This is usually embedded in exceptions. // When working with error codes, we need to log it explicitly if (!diag.client_message().empty()) { std::cerr << ": " << diag.client_message(); } // server_message() contains server-side messages, and thus may // contain user-supplied input. Printing it is safe. if (!diag.server_message().empty()) { std::cerr << ": " << diag.server_message(); } // Done std::cerr << std::endl; }
diagnostics::client_message
and diagnostics::server_message
differ in their origin. Client messages never contain user-supplied input,
and can always be used safely. Server messages may contain user input, and
should be treated with more caution (logging them is fine).
Since asio::read
and asio::write
are
compliant async operations, we can use asio::as_tuple
with them, too:
asio::awaitable<void> handle_session(mysql::connection_pool& pool, asio::ip::tcp::socket client_socket) { // Read the request from the client. unsigned char message[8]{}; auto [ec1, bytes_read] = co_await asio::async_read(client_socket, asio::buffer(message), asio::as_tuple); if (ec1) { log_error("Error reading from the socket", ec1); co_return; } // Process the request as before (omitted) // Write the response back to the client. auto [ec2, bytes_written] = co_await asio::async_write( client_socket, asio::buffer(response), asio::as_tuple ); if (ec2) { log_error("Error writing to the socket", ec2); co_return; } }
Our session handler has three logical steps:
Each of these steps may take long to complete. We will set a separate timeout to each one.
Client reads and writes are the easiest ones to handle - we just need to combine
as_tuple
and cancel_after
:
// Read the request from the client. // async_read ensures that the 8-byte buffer is filled, handling partial reads. // Error the read if it hasn't completed after 30 seconds. unsigned char message[8]{}; auto [ec1, bytes_read] = co_await asio::async_read( client_socket, asio::buffer(message), asio::cancel_after(30s, asio::as_tuple) ); if (ec1) { // An error or a timeout happened. log_error("Error reading from the socket", ec1); co_return; }
The database logic is more involved. Ideally, we would like to set a timeout
to the overall database access operation, rather than to individual steps.
However, a co_await
expression
isn't an async operation, and can't be passed a completion token. We can fix
this by replacing plain co_await
by asio::co_spawn
, which accepts a completion token:
// Invoke the database handling logic. // Apply an overall timeout of 20 seconds to the entire coroutine. // Using asio::co_spawn allows us to pass a completion token, like asio::cancel_after. // As other async operations, co_spawn's default completion token allows // us to use co_await on its return value. std::string response = co_await asio::co_spawn( // Run the child coroutine using the same executor as this coroutine co_await asio::this_coro::executor, // The coroutine should run our database logic [&pool, employee_id] { return get_employee_details(pool, employee_id); }, // Apply a timeout, and return an object that can be co_awaited. // We don't use as_tuple here because we're already handling I/O errors // inside get_employee_details. If an unexpected exception happens, propagate it. asio::cancel_after(20s) );
With these modifications, the session handler becomes:
asio::awaitable<void> handle_session(mysql::connection_pool& pool, asio::ip::tcp::socket client_socket) { // Enable the use of the "s" suffix for std::chrono::seconds using namespace std::chrono_literals; // Read the request from the client. // async_read ensures that the 8-byte buffer is filled, handling partial reads. // Error the read if it hasn't completed after 30 seconds. unsigned char message[8]{}; auto [ec1, bytes_read] = co_await asio::async_read( client_socket, asio::buffer(message), asio::cancel_after(30s, asio::as_tuple) ); if (ec1) { // An error or a timeout happened. log_error("Error reading from the socket", ec1); co_return; } // Parse the 64-bit big-endian int into a native int64_t std::int64_t employee_id = boost::endian::load_big_s64(message); // Invoke the database handling logic. // Apply an overall timeout of 20 seconds to the entire coroutine. // Using asio::co_spawn allows us to pass a completion token, like asio::cancel_after. // As other async operations, co_spawn's default completion token allows // us to use co_await on its return value. std::string response = co_await asio::co_spawn( // Run the child coroutine using the same executor as this coroutine co_await asio::this_coro::executor, // The coroutine should run our database logic [&pool, employee_id] { return get_employee_details(pool, employee_id); }, // Apply a timeout, and return an object that can be co_awaited. // We don't use as_tuple here because we're already handling I/O errors // inside get_employee_details. If an unexpected exception happens, propagate it. asio::cancel_after(20s) ); // Write the response back to the client. // async_write ensures that the entire message is written, handling partial writes. // Set a timeout to the write operation, too. auto [ec2, bytes_written] = co_await asio::async_write( client_socket, asio::buffer(response), asio::cancel_after(30s, asio::as_tuple) ); if (ec2) { log_error("Error writing to the socket", ec2); co_return; } // The socket's destructor will close the client connection }
With these modifications, our server is ready!
Full program listing for this tutorial is here.
This concludes our tutorial series. You can now look at the overview section to learn more about the library features, or to the example section if you prefer to learn by doing.
[1]
with_diagnostics
is an adapter completion token that enhances thrown exceptions with a diagnostic
string supplied by the server. mysql::with_diagnostics(asio::deferred)
is otherwise equivalent to asio::deferred
.