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
+10 -15
View File
@@ -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