03 — Error Handling¶
3.1 The problem being solved¶
libssh reports failures through four unrelated channels:
- Sentinel return codes:
SSH_OK(0),SSH_ERROR(-1),SSH_AGAIN(-2),SSH_EINTR(-3). - Auth-specific codes:
SSH_AUTH_SUCCESS/DENIED/PARTIAL/INFO/AGAIN/ERROR. - Session-attached error state:
ssh_get_error(session)→const char*,ssh_get_error_code(session)→SSH_NO_ERROR | SSH_REQUEST_DENIED | SSH_FATAL | SSH_EINTR. - SFTP status codes:
sftp_get_error(sftp)→SSH_FX_OK … SSH_FX_INVALID_PARAMETER.
Plus host-key results (ssh_known_hosts_e) which are not errors but are frequently
mis-handled as such.
libsshpp normalizes all of these onto std::error_code.
3.2 Error categories¶
Three std::error_category instances, all reachable from <sshpp/error.hpp>:
| Category | Enum | Source |
|---|---|---|
sshpp::ssh_category() |
sshpp::errc |
libssh session/transport/auth/protocol failures |
sshpp::sftp_category() |
sshpp::sftp_errc |
SSH_FX_* status codes |
std::system_category() |
— | OS-level failures surfaced by libssh (socket, DNS) |
namespace sshpp {
enum class errc : int {
ok = 0,
// --- transport / session -------------------------------------------
fatal, // SSH_FATAL: connection unusable
request_denied, // SSH_REQUEST_DENIED
interrupted, // SSH_EINTR
would_block, // SSH_AGAIN in non-blocking mode
timed_out,
cancelled, // Session::request_cancel()
not_connected,
already_connected,
connection_lost,
protocol_error,
banner_exchange_failed,
key_exchange_failed,
rekey_failed,
// --- host key ------------------------------------------------------
host_key_unknown, // not in known_hosts
host_key_changed, // MITM warning
host_key_type_mismatch,// known host, different key type
host_key_rejected, // verifier policy said no
known_hosts_io_error,
// --- authentication -------------------------------------------------
auth_denied,
auth_partial, // more methods required
auth_method_unavailable,
auth_no_more_methods,
passphrase_required,
passphrase_incorrect,
agent_unavailable,
gssapi_error,
// --- keys -----------------------------------------------------------
key_import_failed,
key_export_failed,
key_generation_failed,
unsupported_key_type,
// --- channels --------------------------------------------------------
channel_open_failed,
channel_closed,
channel_eof,
channel_request_failed,
pty_request_failed,
// --- subsystems -------------------------------------------------------
sftp_unavailable,
scp_error,
forwarding_failed,
x11_failed,
// --- wrapper-level ----------------------------------------------------
invalid_handle, // moved-from / released object used
invalid_argument,
unsupported_operation, // linked libssh too old for this call
out_of_memory,
unknown,
};
enum class sftp_errc : int {
ok = 0, // SSH_FX_OK
eof = 1, // SSH_FX_EOF
no_such_file = 2,
permission_denied = 3,
failure = 4,
bad_message = 5,
no_connection = 6,
connection_lost = 7,
op_unsupported = 8,
invalid_handle = 9,
no_such_path = 10,
file_already_exists = 11,
write_protect = 12,
no_media = 13,
// libssh extension range
invalid_parameter = 14,
};
SSHPP_API const std::error_category& ssh_category() noexcept;
SSHPP_API const std::error_category& sftp_category() noexcept;
SSHPP_API std::error_code make_error_code(errc e) noexcept;
SSHPP_API std::error_code make_error_code(sftp_errc e) noexcept;
} // namespace sshpp
namespace std {
template <> struct is_error_code_enum<sshpp::errc> : true_type {};
template <> struct is_error_code_enum<sshpp::sftp_errc> : true_type {};
}
Both categories implement default_error_condition() so that common cases map onto
std::errc and interoperate with generic code:
sshpp::errc |
std::errc condition |
|---|---|
timed_out |
timed_out |
would_block |
operation_would_block |
interrupted |
interrupted |
cancelled |
operation_canceled |
connection_lost |
connection_aborted |
not_connected |
not_connected |
invalid_argument |
invalid_argument |
out_of_memory |
not_enough_memory |
unsupported_operation |
function_not_supported |
auth_denied, permission_denied (sftp) |
permission_denied |
sftp_errc::no_such_file / no_such_path |
no_such_file_or_directory |
sftp_errc::file_already_exists |
file_exists |
So if (ec == std::errc::timed_out) works regardless of which category produced it.
3.3 ErrorInfo — keeping the textual reason¶
std::error_code alone loses libssh's ssh_get_error() string, which is often the only way to
tell why key exchange failed. Every failure therefore carries an ErrorInfo:
namespace sshpp {
struct ErrorInfo {
std::error_code code; // never default-constructed on failure
std::string message; // ssh_get_error() snapshot, may be empty
const char* operation = ""; // static literal, e.g. "ssh_userauth_publickey"
SourceLocation where{}; // file/line/function; std::source_location under C++20
[[nodiscard]] std::string to_string() const; // "connect: <msg> [ssh:key_exchange_failed]"
explicit operator bool() const noexcept { return static_cast<bool>(code); }
};
} // namespace sshpp
ErrorInfo is captured at the point of failure, before any other libssh call can overwrite
the session's error string. This is enforced by routing every C call through a single helper:
// detail/invoke.hpp
template <class F, class... Args>
auto detail::checked(SessionCore& core, const char* op, F&& fn, Args&&... args)
-> Result<detail::invoke_result_t<F, Args...>>;
checked() calls fn, inspects the return value, and on failure snapshots
ssh_get_error(core.raw()) and ssh_get_error_code(...) immediately.
3.4 Exception hierarchy¶
namespace sshpp {
class SSHPP_API Error : public std::system_error {
public:
explicit Error(ErrorInfo info);
const ErrorInfo& info() const noexcept;
const char* operation() const noexcept;
const SourceLocation& where() const noexcept;
// what() == info().to_string()
};
class SSHPP_API ConnectionError : public Error {}; // transport/session failures
class SSHPP_API TimeoutError : public ConnectionError {};
class SSHPP_API CancelledError : public ConnectionError {};
class SSHPP_API HostKeyError : public Error {
public:
KnownHostsStatus status() const noexcept; // unknown / changed / other / not_found
const PublicKey& presented_key() const noexcept;
Fingerprint fingerprint(HashType) const;
};
class SSHPP_API AuthError : public Error {
public:
AuthMethods remaining_methods() const noexcept; // from ssh_userauth_list
bool partial() const noexcept;
};
class SSHPP_API KeyError : public Error {};
class SSHPP_API ChannelError : public Error {};
class SSHPP_API SftpError : public Error {
public:
sftp_errc status() const noexcept;
const RemotePath& path() const noexcept; // empty if not path-specific
};
class SSHPP_API ScpError : public Error {};
class SSHPP_API ForwardingError : public ChannelError {};
class SSHPP_API ServerError : public Error {};
class SSHPP_API UsageError : public Error {}; // invalid_handle, invalid_argument
Deriving from std::system_error means catch (const std::system_error&) and e.code() work
for callers who don't know about libsshpp, and catch (const std::exception&) always works.
Mapping from errc to exception class is a single throw_error(ErrorInfo) function in
error.cpp; no other file throws directly.
Destructors never throw. Session::~Session, Channel::~Channel, etc. call the
non-throwing paths and, if a close fails, route the ErrorInfo to
Library::set_destructor_error_handler() (default: no-op; test builds: abort).
3.5 Result<T>¶
namespace sshpp {
#if SSHPP_HAS_STD_EXPECTED // C++23
template <class T> using Result = std::expected<T, ErrorInfo>;
using Unexpected = std::unexpected<ErrorInfo>;
#else
template <class T> class Result; // expected-like backport, see below
#endif
} // namespace sshpp
The C++17 backport implements the subset of std::expected that we commit to, so migrating to
std::expected later is source-compatible:
template <class T>
class Result {
public:
using value_type = T;
using error_type = ErrorInfo;
Result(T value); // implicit, success
Result(ErrorInfo error); // implicit, failure
Result(Unexpected e);
[[nodiscard]] bool has_value() const noexcept;
explicit operator bool() const noexcept;
T& value() &; // throws sshpp::Error if !has_value()
const T& value() const&;
T&& value() &&;
template <class U> T value_or(U&& fallback) const&;
T* operator->() noexcept; // UB if !has_value()
T& operator*() & noexcept;
const ErrorInfo& error() const& noexcept;
std::error_code code() const noexcept; // {} on success
// monadic subset (available in C++17 backport too)
template <class F> auto and_then(F&&) &&;
template <class F> auto transform(F&&) &&;
template <class F> auto or_else(F&&) &&;
void throw_if_error() const; // no-op on success
};
template <> class Result<void>; // same, without value()
Result<T> is [[nodiscard]].
Convenience macros (optional, opt-in header <sshpp/try.hpp>)¶
#define SSHPP_TRY(expr) // GCC/Clang statement-expression: unwraps or returns the error
#define SSHPP_TRY_ASSIGN(var, expr)
Portable fallback for MSVC uses and_then. Macros are not included by sshpp.hpp.
3.6 The dual API rule¶
For every fallible public operation there are exactly two functions:
class Session {
public:
// Non-throwing. The primitive; the throwing one is generated from it.
[[nodiscard]] Result<void> try_connect() noexcept;
// Throwing. Equivalent to `try_connect().value()`.
void connect();
};
Generation is mechanical, via a macro in detail/throwing_wrapper.hpp, so the two can never
drift:
#define SSHPP_THROWING(RET, NAME, SIG, ARGS) \
RET NAME SIG { auto r = try_##NAME ARGS; r.throw_if_error(); \
if constexpr (!std::is_void_v<RET>) return std::move(r).value(); }
Rules:
try_*functions arenoexceptunless they allocate astd::string/container for the result (documented per function). They never throwsshpp::Error.- Throwing functions are never
noexcept. - Only the
try_*form touches libssh. - Constructors cannot follow this pattern, so constructors never connect or perform I/O.
Objects are constructed empty/configured, and a named method performs the fallible step
(
Session::connect,Sftp::open,Bind::listen). Static factory helpers that do perform I/O returnResult<T>(e.g.Key::try_from_file).
3.7 Non-error conditions that look like errors¶
Some libssh results are legitimate outcomes and must not be exceptions on the primary path:
| Situation | API |
|---|---|
Host key not in known_hosts |
Session::check_known_host() returns KnownHostsStatus, not an error. Only Session::verify_host_key(policy) can fail. |
| Auth requires more methods | Session::try_authenticate(...) returns AuthStatus::partial; only authenticate_or_throw style helpers throw. |
read() hits EOF |
Channel::read_some() returns 0 bytes and Channel::is_eof() is true. Result is a success. |
SFTP readdir exhausted |
DirectoryIterator compares equal to end(). |
SCP pull_request returns SSH_SCP_REQUEST_EOF |
scp::Reader::next() returns std::nullopt. |
Non-blocking SSH_AGAIN |
errc::would_block — an error code, but explicitly documented as retryable and never thrown by wait_* helpers. |
AuthStatus, KnownHostsStatus, ScpRequestType are therefore first-class enums, not codes.
3.8 Worked example¶
#include <sshpp/sshpp.hpp>
#include <iostream>
int main() try {
sshpp::Library lib; // ssh_init + threading callbacks
sshpp::SessionOptions opts;
opts.host = "example.com";
opts.user = "deploy";
opts.timeout = std::chrono::seconds{10};
sshpp::Session session{opts};
session.connect(); // throws ConnectionError / TimeoutError
session.verify_host_key(sshpp::StrictHostKeyPolicy{});// throws HostKeyError
session.authenticate(sshpp::auth::PublicKeyAuto{}); // throws AuthError
auto result = sshpp::Exec{session}.run("uname -a"); // throws ChannelError
std::cout << result.stdout_text;
return result.exit_code;
}
catch (const sshpp::HostKeyError& e) {
std::cerr << "host key " << to_string(e.status()) << ": "
<< e.fingerprint(sshpp::HashType::sha256).to_string() << '\n';
return 2;
}
catch (const sshpp::Error& e) {
std::cerr << e.what() << '\n'; // includes ssh_get_error() text
return 1;
}
Same program, non-throwing:
sshpp::Library lib;
sshpp::Session session{opts};
if (auto r = session.try_connect(); !r) {
std::cerr << r.error().to_string() << '\n';
return 1;
}
if (auto r = session.try_verify_host_key(sshpp::StrictHostKeyPolicy{}); !r) {
if (r.code() == sshpp::errc::host_key_changed) { /* loud warning */ }
return 2;
}
auto exec = sshpp::Exec{session}.try_run("uname -a");
if (!exec) { std::cerr << exec.error().to_string() << '\n'; return 1; }
std::cout << exec->stdout_text;