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
View File
@@ -296,6 +296,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
bool balanceOk = false;
bool blockOk = false;
double balanceScanMs = 0.0;
rpc::RpcFailureKind blockErrKind = rpc::RpcFailureKind::None;
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
@@ -306,6 +308,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
blockInfo = rpc.call("getblockchaininfo", json::array());
blockOk = true;
} catch (const std::exception& e) {
blockErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
@@ -330,6 +333,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
balanceErrKind = rpc::rpcFailureKindOf(e);
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
@@ -341,6 +345,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
result.blockchainErrKind = blockErrKind;
result.balanceErrKind = balanceErrKind;
return result;
}
+5
View File
@@ -4,6 +4,7 @@
#include "data/wallet_state.h"
#include "refresh_scheduler.h"
#include "rpc/rpc_worker.h"
#include "rpc/rpc_failure.h"
#include <nlohmann/json.hpp>
@@ -112,6 +113,10 @@ public:
std::optional<int> longestChain;
std::optional<int> notarized;
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
// How those calls failed (None when they succeeded or weren't made), so a caller can tell a busy
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
};
struct MiningRefreshResult {