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:
+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"];
|
||||
|
||||
Reference in New Issue
Block a user