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());
+74 -67
View File
@@ -225,6 +225,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
last_connect_kind_ = RpcFailureKind::Other; // until the probe says otherwise
try {
json result = call("getinfo", json::array(), probeTimeout);
if (result.contains("version")) {
@@ -232,36 +233,32 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
warming_up_ = false;
warmup_status_.clear();
last_connect_error_.clear();
last_connect_kind_ = RpcFailureKind::None;
last_connect_info_ = result;
if (connect_error_repeats_ > 0)
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
logged_connect_error_.clear();
connect_error_repeats_ = 0;
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
return true;
}
} catch (const std::exception& e) {
last_connect_error_ = e.what();
// Daemon warmup messages (Loading block index, Verifying blocks, etc.)
// are normal startup progress — the daemon is reachable and auth works,
// it just hasn't finished initializing yet. Mark as connected+warmup
// so the wallet can show the UI instead of a blocking overlay.
last_connect_kind_ = rpcFailureKindOf(e);
// Daemon warmup (Loading block index, Verifying blocks, etc.) is normal startup progress — the
// daemon is reachable and auth works, it just hasn't finished initializing yet. Mark as
// connected+warmup so the wallet can show the UI instead of a blocking overlay. Only the
// JSON-RPC code (-28, RPC_IN_WARMUP) counts: matching the message text would also match a
// plain-text HTTP body that happens to say "Loading".
std::string msg = e.what();
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
// to message substrings for any path that didn't carry the numeric code.
int code = 0;
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
bool isWarmup = (code == -28) ||
(msg.find("Loading") != std::string::npos ||
msg.find("Verifying") != std::string::npos ||
msg.find("Activating") != std::string::npos ||
msg.find("Rewinding") != std::string::npos ||
msg.find("Rescanning") != std::string::npos ||
msg.find("Pruning") != std::string::npos);
if (isWarmup) {
if (last_connect_kind_ == RpcFailureKind::Warmup) {
connected_ = true;
warming_up_ = true;
warmup_status_ = msg;
DEBUG_LOGF("Daemon warming up: %s\n", msg.c_str());
return true;
} else {
DEBUG_LOGF("Connection failed: %s\n", msg.c_str());
logConnectFailure(msg);
}
}
@@ -272,6 +269,23 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
return false;
}
// A busy or absent daemon makes every connect() fail the same way, and logging each one flooded the
// log (60k lines in one incident). Log a new error at once; a repeat only every 100th time.
void RPCClient::logConnectFailure(const std::string& msg)
{
const std::string line = std::string("[") + rpcFailureKindName(last_connect_kind_) + "] " + msg;
if (line != logged_connect_error_) {
if (connect_error_repeats_ > 0)
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
logged_connect_error_ = line;
connect_error_repeats_ = 0;
DEBUG_LOGF("Connection failed %s\n", line.c_str());
return;
}
if (++connect_error_repeats_ % 100 == 0)
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
}
json RPCClient::getLastConnectInfo() const
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
@@ -335,7 +349,13 @@ std::string RPCClient::performCall(const std::string& method, const json& params
CURLcode res = curl_easy_perform(impl_->curl);
if (res != CURLE_OK) {
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
// libcurl reports connect- and reply-phase timeouts with the same code. The request size
// (reset for every transfer) tells them apart: 0 means it never reached the daemon.
long requestSize = 0;
curl_easy_getinfo(impl_->curl, CURLINFO_REQUEST_SIZE, &requestSize);
const bool sent = requestSize > 0;
throw RpcTransportError(classifyRpcFailure(res, sent, 0, 0, std::string()),
"RPC request failed: " + std::string(curl_easy_strerror(res)), res, 0, sent);
}
// Check HTTP response code
@@ -345,28 +365,37 @@ std::string RPCClient::performCall(const std::string& method, const json& params
return response_data;
}
void RPCClient::throwHttpFailure(long httpCode, const std::string& body, bool hideBody)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors (insufficient funds, bad
// params, etc.), and 400/404 for a bad request / unknown method, with a JSON body carrying the
// real message. Parse the body first to extract it.
bool isJson = false;
try {
json response = json::parse(body);
isJson = true;
if (response.contains("error") && response["error"].is_object()) {
int errCode = 0;
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>(), httpCode);
// message missing/non-string — keep the detail instead of a bare HTTP code
throw RpcError(errCode, "RPC error: " + response["error"].dump(), httpCode);
}
} catch (const json::exception&) {
// Not JSON: the daemon's HTTP layer refused the request itself ("Work queue depth exceeded",
// "Unhandled request") or sent a 401/503 page.
}
const std::string plain = isJson ? std::string() : body;
throw RpcTransportError(classifyRpcFailure(CURLE_OK, true, httpCode, 0, plain),
formatHttpError(httpCode, hideBody ? std::string() : plain),
CURLE_OK, httpCode, true);
}
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
// (insufficient funds, bad params, etc.) with a valid JSON body.
// Parse the body first to extract the real error message.
if (httpCode != 200) {
int errCode = 0;
try {
json response = json::parse(body);
if (response.contains("error") && response["error"].is_object()) {
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
errCode = response["error"]["code"].get<int>();
if (response["error"].contains("message") && response["error"]["message"].is_string())
throw RpcError(errCode, response["error"]["message"].get<std::string>());
// message missing/non-string — keep the detail instead of a bare HTTP code
throw RpcError(errCode, "RPC error: " + response["error"].dump());
}
} catch (const json::exception&) {
// Body wasn't valid JSON — fall through to generic HTTP error
}
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(httpCode));
}
if (httpCode != 200) throwHttpFailure(httpCode, body, /*hideBody=*/scrubSource);
json response = json::parse(body);
@@ -380,7 +409,7 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
err_msg = response["error"]["message"].get<std::string>();
}
if (err_msg.empty()) err_msg = response["error"].dump();
throw RpcError(errCode, "RPC error: " + err_msg);
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
}
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
@@ -481,34 +510,9 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
throw std::runtime_error("Not connected");
}
emitRpcTrace(method);
json payload = makePayload(method, params);
std::string body = payload.dump();
std::string response_data;
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size());
curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data);
CURLcode res = curl_easy_perform(impl_->curl);
if (res != CURLE_OK) {
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
}
long http_code = 0;
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
if (http_code != 200) {
try {
json response = json::parse(response_data);
if (response.contains("error") && !response["error"].is_null()) {
std::string err_msg = response["error"]["message"].get<std::string>();
throw std::runtime_error(err_msg);
}
} catch (const json::exception&) {}
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
}
std::string response_data = performCall(method, params, http_code);
if (http_code != 200) throwHttpFailure(http_code, response_data, /*hideBody=*/false);
// Parse with ordered_json to preserve the daemon's original key order
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
@@ -517,11 +521,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
const auto& err = oj["error"];
std::string err_msg;
int errCode = 0;
if (err.is_object() && err.contains("code") && err["code"].is_number_integer())
errCode = err["code"].get<int>();
if (err.is_object() && err.contains("message") && err["message"].is_string())
err_msg = err["message"].get<std::string>();
else
err_msg = err.dump();
throw std::runtime_error("RPC error: " + err_msg);
throw RpcError(errCode, "RPC error: " + err_msg, http_code);
}
auto& result = oj["result"];
+10 -15
View File
@@ -5,6 +5,7 @@
#pragma once
#include "types.h"
#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind
#include <atomic>
#include <string>
#include <functional>
@@ -20,21 +21,6 @@ using json = nlohmann::json;
using Callback = std::function<void(const json&)>;
using ErrorCallback = std::function<void(const std::string&)>;
/**
* @brief A JSON-RPC error carrying the daemon's numeric error code.
*
* what() preserves the exact human-readable message (so existing string matching
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
* for a daemon still starting up. Derives from std::runtime_error, so every existing
* `catch (const std::exception&)` continues to handle it unchanged.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message)
: std::runtime_error(message), code(errorCode) {}
int code = 0;
};
/**
* @brief JSON-RPC client for dragonxd
*
@@ -117,6 +103,8 @@ public:
* @brief Get the error message from the last failed connect() attempt.
*/
const std::string& getLastConnectError() const { return last_connect_error_; }
/** @brief How the last connect() probe went: None on success, Warmup for a warming-up node, else the failure. */
RpcFailureKind getLastConnectFailureKind() const { return last_connect_kind_; }
json getLastConnectInfo() const;
static void setTraceCallback(TraceCallback callback);
@@ -274,6 +262,10 @@ private:
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
void logConnectFailure(const std::string& msg);
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
@@ -286,6 +278,9 @@ private:
bool warming_up_ = false;
std::string warmup_status_;
std::string last_connect_error_;
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
std::string logged_connect_error_; // last "Connection failed" text logged (rate limit)
unsigned connect_error_repeats_ = 0; // identical failures since it was logged
json last_connect_info_;
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
+154
View File
@@ -0,0 +1,154 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
// test target can use it without compiling rpc_client.cpp.
#include <curl/curl.h>
#include <stdexcept>
#include <string>
namespace dragonx {
namespace rpc {
enum class RpcFailureKind {
None, // the call succeeded
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
AppError, // the daemon ran the method and answered with a JSON-RPC error
Aborted, // our own requestAbort() cut the transfer
Other,
};
inline const char* rpcFailureKindName(RpcFailureKind kind)
{
switch (kind) {
case RpcFailureKind::None: return "None";
case RpcFailureKind::NotListening: return "NotListening";
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
case RpcFailureKind::QueueFull: return "QueueFull";
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
case RpcFailureKind::Unhandled: return "Unhandled";
case RpcFailureKind::Auth: return "Auth";
case RpcFailureKind::Warmup: return "Warmup";
case RpcFailureKind::AppError: return "AppError";
case RpcFailureKind::Aborted: return "Aborted";
case RpcFailureKind::Other: return "Other";
}
return "Other";
}
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
inline bool isRpcTimeout(RpcFailureKind kind)
{
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
}
/**
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
* connect- and reply-phase timeouts with the same code; this tells them apart.
* @param httpStatus HTTP status, 0 when there was no response
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
*/
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
const std::string& plainBody)
{
if (curlCode != CURLE_OK) {
switch (curlCode) {
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
case CURLE_OPERATION_TIMEDOUT:
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
default: return RpcFailureKind::Other;
}
}
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
if (httpStatus == 200) return RpcFailureKind::None;
if (httpStatus == 401) return RpcFailureKind::Auth;
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
if (httpStatus == 500) {
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
}
return RpcFailureKind::Other;
}
/**
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
*/
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
{
std::string text;
bool pendingSpace = false;
for (unsigned char c : plainBody) {
if (text.size() >= 120) break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
pendingSpace = !text.empty();
} else if (c >= 0x21 && c <= 0x7e) {
if (pendingSpace && text.size() < 119) text.push_back(' ');
pendingSpace = false;
text.push_back(static_cast<char>(c));
}
}
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
if (!text.empty()) out += ": " + text;
return out;
}
/**
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message, long status = 0)
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
int code = 0;
long httpStatus = 0;
};
/**
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
*/
class RpcTransportError : public std::runtime_error {
public:
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
long status = 0, bool sent = false)
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
RpcFailureKind kind = RpcFailureKind::Other;
int curlCode = CURLE_OK;
long httpStatus = 0;
bool requestSent = false;
};
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
{
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
return RpcFailureKind::Other;
}
} // namespace rpc
} // namespace dragonx
+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 {
+111
View File
@@ -13,6 +13,7 @@
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "rpc/rpc_failure.h"
#include "resources/embedded_resources.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
@@ -645,6 +646,7 @@ public:
nlohmann::json value;
bool throws = false;
std::string error = "mock rpc failure";
std::exception_ptr exception; // rethrown as-is when set (e.g. an RpcTransportError)
};
std::vector<RecordedCall> calls;
@@ -660,6 +662,11 @@ public:
responses[method].push_back({nlohmann::json(), true, std::move(error)});
}
void addException(const std::string& method, std::exception_ptr exception)
{
responses[method].push_back({nlohmann::json(), true, {}, std::move(exception)});
}
nlohmann::json call(const std::string& method, const nlohmann::json& params) override
{
calls.push_back({method, params});
@@ -669,6 +676,7 @@ public:
}
auto response = std::move(queue.front());
queue.pop_front();
if (response.exception) std::rethrow_exception(response.exception);
if (response.throws) throw std::runtime_error(response.error);
return response.value;
}
@@ -682,6 +690,88 @@ public:
}
};
// S7: RPC failures are classified from structured data (curl code, request-sent bit, HTTP status,
// JSON-RPC code, plain-text body), never from what() text.
void testClassifyRpcFailure()
{
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::classifyRpcFailure;
using dragonx::rpc::rpcFailureKindName;
using K = RpcFailureKind;
struct Row { int curl; bool sent; long http; int rpcCode; const char* body; K want; };
const Row rows[] = {
{CURLE_OK, true, 200, 0, "", K::None},
{CURLE_COULDNT_CONNECT, false, 0, 0, "", K::NotListening},
{CURLE_OPERATION_TIMEDOUT, false, 0, 0, "", K::ConnectTimeout}, // never reached the daemon
{CURLE_OPERATION_TIMEDOUT, true, 0, 0, "", K::ReplyTimeout}, // sent; the daemon holds it
{CURLE_ABORTED_BY_CALLBACK, true, 0, 0, "", K::Aborted},
{CURLE_GOT_NOTHING, true, 0, 0, "", K::Other},
{CURLE_OK, true, 500, 0, "Work queue depth exceeded", K::QueueFull},
{CURLE_OK, true, 500, 0, "Unhandled request", K::Unhandled},
{CURLE_OK, true, 500, 0, "", K::Other},
{CURLE_OK, true, 503, 0, "<HTML><TITLE>503 Service Unavailable</TITLE></HTML>", K::ShuttingDown},
{CURLE_OK, true, 401, 0, "", K::Auth},
{CURLE_OK, true, 500, -6, "", K::AppError}, // "Insufficient funds"
{CURLE_OK, true, 500, -28, "", K::Warmup}, // "Loading block index..."
{CURLE_OK, true, 404, -32601, "", K::AppError}, // Method not found
{CURLE_OK, true, 400, -32600, "", K::AppError}, // Invalid request
{CURLE_OK, true, 200, -28, "", K::Warmup},
// Body text is never read as warmup or auth, whatever it says.
{CURLE_OK, true, 500, 0, "Loading block index... 401", K::Other},
};
for (const auto& r : rows) {
const K got = classifyRpcFailure(r.curl, r.sent, r.http, r.rpcCode, r.body);
if (got != r.want)
std::cerr << "classifyRpcFailure(curl=" << r.curl << ", sent=" << r.sent << ", http=" << r.http
<< ", rpc=" << r.rpcCode << ", \"" << r.body << "\") = " << rpcFailureKindName(got)
<< ", want " << rpcFailureKindName(r.want) << "\n";
EXPECT_TRUE(got == r.want);
}
EXPECT_TRUE(dragonx::rpc::isRpcTimeout(K::ConnectTimeout));
EXPECT_TRUE(dragonx::rpc::isRpcTimeout(K::ReplyTimeout));
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
}
void testFormatHttpError()
{
using dragonx::rpc::formatHttpError;
// dragonxd's plain-text refusal is kept, so the log says what happened instead of a bare "HTTP 500".
EXPECT_EQ(formatHttpError(500, "Work queue depth exceeded"),
std::string("RPC error: HTTP 500: Work queue depth exceeded"));
EXPECT_EQ(formatHttpError(401, ""), std::string("RPC error: HTTP 401")); // empty body: exactly the old text
EXPECT_EQ(formatHttpError(503, " \r\n "), std::string("RPC error: HTTP 503"));
// A long HTML page with control characters: bounded to 120 chars, printable ASCII only.
std::string html = "<HTML>\n<HEAD>\x01\x02\x7f";
while (html.size() < 300) html += "<P>junk junk</P>\n";
const std::string s = formatHttpError(502, html);
const std::string prefix = "RPC error: HTTP 502: ";
EXPECT_TRUE(s.rfind(prefix + "<HTML> <HEAD><P>junk", 0) == 0);
EXPECT_TRUE(s.size() - prefix.size() <= 120);
bool printable = true;
for (unsigned char c : s) if (c < 0x20 || c > 0x7e) printable = false;
EXPECT_TRUE(printable);
}
void testRpcFailureKindOf()
{
using namespace dragonx::rpc;
using K = RpcFailureKind;
EXPECT_TRUE(rpcFailureKindOf(RpcError(-28, "Loading block index...")) == K::Warmup);
EXPECT_TRUE(rpcFailureKindOf(RpcError(-6, "Insufficient funds")) == K::AppError);
EXPECT_TRUE(rpcFailureKindOf(RpcTransportError(K::QueueFull, "RPC error: HTTP 500")) == K::QueueFull);
EXPECT_TRUE(rpcFailureKindOf(std::runtime_error("Not connected")) == K::Other);
// Callers catch RpcError to mean "the daemon answered no" (e.g. no mnemonic -> fall back); a
// transport failure must not be mistaken for that.
bool caughtAsRpcError = false;
try {
throw RpcTransportError(K::ReplyTimeout, "RPC request failed: Timeout was reached");
} catch (const RpcError&) {
caughtAsRpcError = true;
} catch (const std::exception&) {
}
EXPECT_FALSE(caughtAsRpcError);
}
void testConnectionConfig()
{
using dragonx::rpc::AuthSource;
@@ -1489,6 +1579,24 @@ void testNetworkRefreshRpcCollectors()
EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk);
EXPECT_EQ(*partialCore.blocks, 8);
EXPECT_TRUE(partialCore.blockchainErrKind == dragonx::rpc::RpcFailureKind::None); // it succeeded
EXPECT_TRUE(partialCore.balanceErrKind == dragonx::rpc::RpcFailureKind::Other); // a plain runtime_error
// S7: the collector reports HOW each call failed, from the exception's type rather than its text.
{
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::RpcTransportError;
MockRefreshRpc busyRpc;
busyRpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError(
RpcFailureKind::QueueFull, "RPC error: HTTP 500: Work queue depth exceeded", CURLE_OK, 500, true)));
busyRpc.addException("z_gettotalbalance", std::make_exception_ptr(RpcTransportError(
RpcFailureKind::ReplyTimeout, "RPC request failed: Timeout was reached", CURLE_OPERATION_TIMEDOUT, 0, true)));
auto busyCore = Refresh::collectCoreRefreshResult(busyRpc);
EXPECT_FALSE(busyCore.blockchainOk);
EXPECT_FALSE(busyCore.balanceOk);
EXPECT_TRUE(busyCore.blockchainErrKind == RpcFailureKind::QueueFull);
EXPECT_TRUE(busyCore.balanceErrKind == RpcFailureKind::ReplyTimeout);
}
MockRefreshRpc peerRpc;
peerRpc.addResponse("getpeerinfo", json::array({
@@ -7826,6 +7934,9 @@ void testWalletIndex()
int main()
{
testConnectionConfig();
testClassifyRpcFailure();
testFormatHttpError();
testRpcFailureKindOf();
testPaymentUri();
testAmountFormatting();
testSpendableFiltering();