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