feat(rpc): classify RPC failures from structured data, not message text

Every caller guessed what went wrong from what(): "Timeout" meant busy,
"401" meant auth, and warmup was code -28 or one of six substrings. A
non-JSON HTTP 500 lost its body, so "Work queue depth exceeded" read as a
bare "HTTP 500", and libcurl reports connect- and reply-phase timeouts
with the same code.

New header-only src/rpc/rpc_failure.h:
- RpcFailureKind (NotListening, ConnectTimeout, ReplyTimeout, QueueFull,
  ShuttingDown, Unhandled, Auth, Warmup, AppError, Aborted, Other) and a
  pure classifyRpcFailure(curl code, request-sent, HTTP status, JSON-RPC
  code, plain-text body)
- formatHttpError keeps a short, printable plain-text body
- RpcError (moved here) stays "the daemon answered with a JSON-RPC
  error". The new RpcTransportError means "no JSON-RPC answer": curl
  failures and HTTP-layer refusals (401, 503, queue full, unhandled).
  Keeping them separate matters: callers catch RpcError to mean "the
  daemon said no" (chat identity's no-mnemonic fallback, seed export),
  so a timeout must not be one. They now treat a queue-full 500 as
  transient rather than as "no".

RPCClient:
- performCall tells the two timeouts apart with CURLINFO_REQUEST_SIZE
  (0 = never reached the daemon; reset on every transfer, checked on
  libcurl 7.81), and callRaw now shares performCall and the HTTP-failure
  path.
- connect() decides warmup by the -28 code only, so a plain-text body
  saying "Loading" can't pass for warmup, and records
  getLastConnectFailureKind().
- "Connection failed" names the kind and is logged once, then every
  100th repeat (one incident logged it 60k times).
- secret-bearing calls never echo a response body.

tryConnect's "401"/"Timeout" checks now use the kind, and
CoreRefreshResult carries blockchainErrKind/balanceErrKind for the busy
handling to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 18:54:23 -05:00
co-authored by Claude Opus 5.5
parent 2c04b88e19
commit d8175b7dc1
7 changed files with 366 additions and 86 deletions
+6 -4
View File
@@ -444,10 +444,12 @@ void App::tryConnect()
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
std::string connectErr = rpc_->getLastConnectError();
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
bool warmingUp = rpc_->isWarmingUp();
std::string warmupStatus = rpc_->getWarmupStatus();
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
connectKind]() {
if (connected) {
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
warmingUp ? " (daemon warming up)" : "");
@@ -474,7 +476,7 @@ void App::tryConnect()
// HTTP 401 = authentication failure. The daemon is running
// but our rpcuser/rpcpassword don't match. Don't retry
// endlessly — tell the user what's wrong.
bool authFailure = (connectErr.find("401") != std::string::npos);
bool authFailure = (connectKind == rpc::RpcFailureKind::Auth);
if (authFailure) {
rpc::ConnectionConfig cookieConfig;
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
@@ -520,7 +522,7 @@ void App::tryConnect()
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
// clear "node initializing" overlay (status + phase + block height from the
// daemon's own console output) instead of a bare technical error.
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
const bool reachableButBusy = rpc::isRpcTimeout(connectKind);
applyDaemonInitStatus(reachableButBusy);
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
attempt, connectErr.c_str());
@@ -531,7 +533,7 @@ void App::tryConnect()
// An external daemon is on the RPC port but not answering. A timeout means it's
// up and busy initializing; surface that as the init overlay (we can't read its
// console since we didn't launch it, so no phase line — just a clear message).
if (connectErr.find("Timeout") != std::string::npos) {
if (rpc::isRpcTimeout(connectKind)) {
applyDaemonInitStatus(/*reachableButBusy=*/true);
} else if (!connectErr.empty()) {
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());