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:
@@ -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