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:
+6
-4
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user