From d8175b7dc129c762df0361240b8c862800a985c6 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 1 Oct 2026 18:54:23 -0500 Subject: [PATCH] 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 --- src/app_network.cpp | 10 +- src/rpc/rpc_client.cpp | 141 +++++++++++---------- src/rpc/rpc_client.h | 25 ++-- src/rpc/rpc_failure.h | 154 +++++++++++++++++++++++ src/services/network_refresh_service.cpp | 6 + src/services/network_refresh_service.h | 5 + tests/test_phase4.cpp | 111 ++++++++++++++++ 7 files changed, 366 insertions(+), 86 deletions(-) create mode 100644 src/rpc/rpc_failure.h diff --git a/src/app_network.cpp b/src/app_network.cpp index ed58e89..4218e98 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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()); diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index 72be115..e0e22e0 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -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()); 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(&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 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(); + if (response["error"].contains("message") && response["error"]["message"].is_string()) + throw RpcError(errCode, response["error"]["message"].get(), 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(); - if (response["error"].contains("message") && response["error"]["message"].is_string()) - throw RpcError(errCode, response["error"]["message"].get()); - // 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(); } 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(); - 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(); if (err.is_object() && err.contains("message") && err["message"].is_string()) err_msg = err["message"].get(); 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"]; diff --git a/src/rpc/rpc_client.h b/src/rpc/rpc_client.h index eb32b45..809687a 100644 --- a/src/rpc/rpc_client.h +++ b/src/rpc/rpc_client.h @@ -5,6 +5,7 @@ #pragma once #include "types.h" +#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind #include #include #include @@ -20,21 +21,6 @@ using json = nlohmann::json; using Callback = std::function; using ErrorCallback = std::function; -/** - * @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 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 diff --git a/src/rpc/rpc_failure.h b/src/rpc/rpc_failure.h new file mode 100644 index 0000000..42b550c --- /dev/null +++ b/src/rpc/rpc_failure.h @@ -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 + +#include +#include + +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 ", 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 " 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(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: " / "RPC error: HTTP [: 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(&e)) return t->kind; + if (const auto* r = dynamic_cast(&e)) return r->kind(); + return RpcFailureKind::Other; +} + +} // namespace rpc +} // namespace dragonx diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 964ecb9..0b17fe5 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -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; } diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index ac94feb..22c4b3e 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -4,6 +4,7 @@ #include "data/wallet_state.h" #include "refresh_scheduler.h" #include "rpc/rpc_worker.h" +#include "rpc/rpc_failure.h" #include @@ -112,6 +113,10 @@ public: std::optional longestChain; std::optional 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 { diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e8a8636..e7b4c67 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 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, "503 Service Unavailable", 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 = "\n\x01\x02\x7f"; + while (html.size() < 300) html += "

junk junk

\n"; + const std::string s = formatHttpError(502, html); + const std::string prefix = "RPC error: HTTP 502: "; + EXPECT_TRUE(s.rfind(prefix + "

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();