Error Handling with io_result
This section explains how to handle errors from I/O operations using io_result
and structured bindings.
The Problem: Exceptions vs Error Codes
I/O operations can fail. Two common approaches:
Exceptions:
auto n = co_await socket.read_some(buffer); // Throws on error
Clean syntax, but:
-
Performance overhead on the error path
-
Error handling can be far from the call site
-
Every operation needs try/catch
Error codes:
error_code ec;
auto n = co_await socket.read_some(buffer, ec); // Sets ec on error
if (ec) handle_error(ec);
Explicit, but:
-
Extra parameter on every call
-
Easy to forget to check
-
Clutters the API
io_result: The Best of Both
io_result<Args…> combines an error code with optional result values:
#include <boost/capy/io_result.hpp>
// Returns error code + bytes transferred
auto [ec, n] = co_await stream.read_some(buffer);
if (ec.failed())
handle_error(ec);
process_data(buffer, n);
Features:
io_result Variants
io_result supports 0-3 additional values:
io_result<> (Error Only)
Operations that succeed or fail without a result value:
auto [ec] = co_await socket.connect(endpoint);
if (ec.failed())
co_return handle_connection_error(ec);
io_result<T1> (Error + One Value)
Operations returning a single value, like bytes transferred:
auto [ec, n] = co_await stream.read_some(buffer);
if (ec.failed())
co_return;
buffer.commit(n); // n bytes were read
Using Structured Bindings
C++17 structured bindings make io_result ergonomic:
// Destructure into separate variables
auto [ec, n] = co_await read_some(buffer);
// ec is system::error_code
// n is std::size_t (or whatever the operation returns)
The binding order matches the template parameters:
io_result<std::size_t> → [ec, n]
io_result<std::size_t, endpoint> → [ec, n, ep]
io_result<> → [ec]
Error Checking Idioms
Check and Handle
auto [ec, n] = co_await stream.read_some(buffer);
if (ec.failed())
{
log_error(ec);
co_return; // Or throw, or return error
}
// Continue with n bytes
Returning io_result from Functions
Custom awaitables can return io_result:
task<io_result<std::size_t>> read_all(stream& s, buffer& buf)
{
std::size_t total = 0;
while (buf.size() < buf.max_size())
{
auto [ec, n] = co_await s.read_some(buf.prepare(1024));
if (ec.failed())
co_return {ec, total}; // Return partial progress
buf.commit(n);
total += n;
}
co_return {{}, total}; // Success with total bytes
}
Error Code vs Exception Trade-offs
Prefer error codes when:
-
Errors are expected (EOF, timeout, cancellation)
-
Performance in error path matters
-
Caller needs to handle specific errors differently
Prefer exceptions when:
-
Errors are truly exceptional
-
Error handling is centralized (top-level catch)
-
Code would be cluttered with error checks
Capy’s recommendation: Use io_result for I/O operations (errors are
common) and exceptions for programmer errors (bugs, invariant violations).
Converting Between Styles
Common Patterns
Read Loop
task<void> read_all(stream& s, dynamic_buffer& buf)
{
while (true)
{
auto [ec, n] = co_await s.read_some(buf.prepare(1024));
if (ec == error::end_of_stream)
co_return; // Normal completion
if (ec.failed())
throw system_error(ec);
buf.commit(n);
}
}
Write with Retry
task<io_result<>> write_with_retry(stream& s, const_buffer data, int retries)
{
for (int i = 0; i < retries; ++i)
{
auto [ec, n] = co_await s.write_some(data);
if (!ec.failed())
{
data += n;
if (data.size() == 0)
co_return {}; // All written
}
// Retry on transient errors
if (ec != error::resource_unavailable_try_again)
co_return {ec};
}
co_return {error::timed_out};
}
Summary
| Type | Contents |
|---|---|
|
Error code only |
|
Error code + one value |
|
Error code + two values |
|
Error code + three values |
Structured bindings |
|
Error check |
|
Next Steps
-
Buffers — Memory management for I/O
-
Stream Concepts — Generic read/write operations