use super::health::{head_block_of, HealthPolicy, HealthTracker, NodeHealth};
use super::types::{DynamicGlobalProperties, RpcRequest, RpcResponse};
use crate::error::{Error, Result};
use crate::transaction::{BlockRef, SignedTransaction};
use futures_util::stream::{FuturesUnordered, StreamExt};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
pub type SleepFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
pub type Sleeper = Arc<dyn Fn(Duration) -> SleepFuture + Send + Sync>;
pub trait AsyncTransport: Send + Sync + std::fmt::Debug {
fn post_json(
&self,
url: &str,
body: &str,
timeout: Duration,
) -> impl Future<Output = Result<String>> + Send;
}
pub struct AsyncNodeClient<T: AsyncTransport> {
transport: Arc<T>,
nodes: Vec<String>,
timeout: Duration,
passes: u32,
initial_backoff: Duration,
sleeper: Option<Sleeper>,
next_id: Arc<AtomicU64>,
health: Option<Arc<HealthTracker>>,
}
impl<T: AsyncTransport> Clone for AsyncNodeClient<T> {
fn clone(&self) -> Self {
AsyncNodeClient {
transport: Arc::clone(&self.transport),
nodes: self.nodes.clone(),
timeout: self.timeout,
passes: self.passes,
initial_backoff: self.initial_backoff,
sleeper: self.sleeper.clone(),
next_id: Arc::clone(&self.next_id),
health: self.health.clone(),
}
}
}
impl<T: AsyncTransport> std::fmt::Debug for AsyncNodeClient<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AsyncNodeClient")
.field("nodes", &self.nodes.len())
.field("timeout", &self.timeout)
.field("passes", &self.passes)
.field("has_sleeper", &self.sleeper.is_some())
.finish()
}
}
impl<T: AsyncTransport> AsyncNodeClient<T> {
pub fn new(transport: T, nodes: Vec<String>) -> Result<Self> {
if nodes.is_empty() {
return Err(Error::Rpc("node list is empty".into()));
}
Ok(AsyncNodeClient {
transport: Arc::new(transport),
nodes,
timeout: Duration::from_secs(10),
passes: 1,
initial_backoff: Duration::from_millis(250),
sleeper: None,
next_id: Arc::new(AtomicU64::new(1)),
health: None,
})
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_health_tracking(mut self, policy: HealthPolicy) -> Self {
self.health = Some(Arc::new(HealthTracker::new(self.nodes.len(), policy)));
self
}
pub fn health(&self) -> Option<Vec<NodeHealth>> {
self.health.as_ref().map(|h| h.snapshot())
}
fn call_order(&self, method: &str) -> Vec<usize> {
match &self.health {
Some(health) => health.order(method),
None => (0..self.nodes.len()).collect(),
}
}
fn note(&self, index: usize, method: &str, outcome: &Result<serde_json::Value>) {
let Some(health) = &self.health else { return };
match outcome {
Ok(value) => {
health.record_success(index, method);
if let Some(head) = head_block_of(value) {
health.observe_head_block(index, head);
}
}
Err(_) => health.record_failure(index, method),
}
}
pub fn with_retries<S, F>(mut self, passes: u32, initial_backoff: Duration, sleep: S) -> Self
where
S: Fn(Duration) -> F + Send + Sync + 'static,
F: Future<Output = ()> + Send + 'static,
{
self.passes = passes.max(1);
self.initial_backoff = initial_backoff;
self.sleeper = Some(Arc::new(move |d| Box::pin(sleep(d)) as SleepFuture));
self
}
pub fn nodes(&self) -> &[String] {
&self.nodes
}
fn request_body(&self, method: &str, params: serde_json::Value) -> Result<String> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
serde_json::to_string(&RpcRequest::new(method, params, id))
.map_err(|e| Error::Rpc(format!("could not encode request: {e}")))
}
async fn try_node(&self, node: &str, body: &str) -> Result<serde_json::Value> {
let text = self.transport.post_json(node, body, self.timeout).await?;
let response: RpcResponse = serde_json::from_str(&text)
.map_err(|e| Error::Rpc(format!("could not parse response: {e}")))?;
response.into_result()
}
pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
let body = self.request_body(method, params)?;
let mut failures = Vec::with_capacity(self.nodes.len());
for pass in 0..self.passes {
if pass > 0 {
if let Some(sleep) = &self.sleeper {
let wait = self
.initial_backoff
.saturating_mul(1u32 << (pass - 1).min(6))
.min(Duration::from_secs(30));
sleep(wait).await;
failures.push(format!("(retry pass {} after {:?})", pass + 1, wait));
}
}
for index in self.call_order(method) {
let node = &self.nodes[index];
let outcome = self.try_node(node, &body).await;
self.note(index, method, &outcome);
match outcome {
Ok(value) => return Ok(value),
Err(e) => failures.push(format!("{node}: {e}")),
}
}
}
Err(Error::Rpc(format!(
"all {} node(s) failed for {method} over {} pass(es) — {}",
self.nodes.len(),
self.passes,
failures.join("; ")
)))
}
pub async fn race(
&self,
method: &str,
params: serde_json::Value,
width: usize,
) -> Result<serde_json::Value> {
let width = width.clamp(1, self.nodes.len());
let body = self.request_body(method, params)?;
let mut inflight = FuturesUnordered::new();
for index in self.call_order(method).into_iter().take(width) {
let node = &self.nodes[index];
let body = body.clone();
inflight.push(async move {
let result = self.try_node(node, &body).await;
self.note(index, method, &result);
(node.clone(), result)
});
}
let mut failures = Vec::with_capacity(width);
while let Some((node, result)) = inflight.next().await {
match result {
Ok(value) => return Ok(value),
Err(e) => failures.push(format!("{node}: {e}")),
}
}
Err(Error::Rpc(format!(
"all {width} raced node(s) failed for {method} — {}",
failures.join("; ")
)))
}
pub async fn dynamic_global_properties(&self) -> Result<DynamicGlobalProperties> {
let value = self
.call(
"database_api.get_dynamic_global_properties",
serde_json::json!({}),
)
.await?;
serde_json::from_value(value)
.map_err(|e| Error::Rpc(format!("unexpected global properties: {e}")))
}
pub async fn global_properties(&self) -> Result<crate::chain::DynamicGlobalProperties> {
let value = self
.call(
"database_api.get_dynamic_global_properties",
serde_json::json!({}),
)
.await?;
serde_json::from_value(value)
.map_err(|e| Error::Rpc(format!("unexpected global properties: {e}")))
}
pub async fn block_ref(&self) -> Result<BlockRef> {
self.dynamic_global_properties().await?.block_ref()
}
pub async fn refresh_tapos(&self, cache: &crate::tapos::TaposCache) -> Result<BlockRef> {
let block_ref = self.block_ref().await?;
cache.store(block_ref);
Ok(block_ref)
}
pub async fn accounts(&self, names: &[&str]) -> Result<Vec<crate::chain::Account>> {
let value = self
.call("condenser_api.get_accounts", serde_json::json!([names]))
.await?;
serde_json::from_value(value)
.map_err(|e| Error::Rpc(format!("unexpected account response: {e}")))
}
pub async fn find_account(&self, name: &str) -> Result<Option<crate::chain::Account>> {
Ok(self.accounts(&[name]).await?.into_iter().next())
}
pub async fn rc_accounts(&self, names: &[&str]) -> Result<Vec<crate::chain::RcAccount>> {
let value = self
.call(
"rc_api.find_rc_accounts",
serde_json::json!({ "accounts": names }),
)
.await?;
serde_json::from_value(
value
.get("rc_accounts")
.cloned()
.ok_or_else(|| Error::Rpc("rc_api response has no rc_accounts".into()))?,
)
.map_err(|e| Error::Rpc(format!("unexpected rc account response: {e}")))
}
pub async fn block(&self, block_num: u32) -> Result<Option<crate::chain::Block>> {
let value = self
.call(
"block_api.get_block",
serde_json::json!({ "block_num": block_num }),
)
.await?;
match value.get("block") {
None | Some(serde_json::Value::Null) => Ok(None),
Some(block) => serde_json::from_value(block.clone())
.map(Some)
.map_err(|e| Error::Rpc(format!("unexpected block response: {e}"))),
}
}
pub async fn ops_in_block(
&self,
block_num: u32,
only_virtual: bool,
) -> Result<Vec<super::BlockOperation>> {
let value = self
.call(
"condenser_api.get_ops_in_block",
serde_json::json!([block_num, only_virtual]),
)
.await?;
serde_json::from_value(value)
.map_err(|e| Error::Rpc(format!("unexpected get_ops_in_block response: {e}")))
}
pub async fn blocks(
&self,
from: u32,
to: u32,
concurrency: usize,
) -> Result<Vec<Option<crate::chain::Block>>> {
if to < from {
return Err(Error::Rpc(format!(
"block range {from}..={to} runs backwards"
)));
}
let concurrency = concurrency.max(1);
let mut fetched: Vec<(u32, Option<crate::chain::Block>)> =
Vec::with_capacity((to - from + 1) as usize);
let mut pending = FuturesUnordered::new();
let mut next = from;
loop {
while pending.len() < concurrency && next <= to {
let number = next;
next += 1;
pending.push(async move { (number, self.block(number).await) });
}
let Some((number, result)) = pending.next().await else {
break;
};
fetched.push((number, result?));
}
fetched.sort_by_key(|(number, _)| *number);
Ok(fetched.into_iter().map(|(_, block)| block).collect())
}
pub async fn broadcast_raced(
&self,
tx: &SignedTransaction,
width: usize,
) -> Result<serde_json::Value> {
self.race(
"network_broadcast_api.broadcast_transaction",
serde_json::json!({ "trx": tx.to_json()? }),
width,
)
.await
}
pub async fn broadcast(&self, tx: &SignedTransaction) -> Result<serde_json::Value> {
self.call(
"network_broadcast_api.broadcast_transaction",
serde_json::json!({ "trx": tx.to_json()? }),
)
.await
}
pub async fn verify_chain_id(&self, chain: crate::chains::Chain) -> Result<()> {
let config = self
.call("database_api.get_config", serde_json::json!({}))
.await?;
let reported = config
.get("HIVE_CHAIN_ID")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Rpc("node config has no HIVE_CHAIN_ID".into()))?;
let expected = chain.chain_id().to_hex();
if reported.eq_ignore_ascii_case(&expected) {
Ok(())
} else {
Err(Error::Chain(format!(
"node reports chain id {reported}, but this build signs for {expected}"
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tokio::time::Instant;
#[derive(Debug)]
struct FakeTransport {
answers: Mutex<std::collections::HashMap<String, (Duration, Result<String>)>>,
calls: Mutex<Vec<String>>,
}
impl FakeTransport {
fn new(answers: Vec<(&str, Duration, Result<String>)>) -> Self {
FakeTransport {
answers: Mutex::new(
answers
.into_iter()
.map(|(node, delay, result)| (node.to_string(), (delay, result)))
.collect(),
),
calls: Mutex::new(Vec::new()),
}
}
fn call_count(&self) -> usize {
self.calls.lock().unwrap().len()
}
}
impl AsyncTransport for FakeTransport {
fn post_json(
&self,
url: &str,
_body: &str,
_timeout: Duration,
) -> impl Future<Output = Result<String>> + Send {
self.calls.lock().unwrap().push(url.to_string());
let entry = self
.answers
.lock()
.unwrap()
.get(url)
.map(|(delay, result)| (*delay, result.clone()));
async move {
match entry {
None => Err(Error::Rpc("no scripted answer".into())),
Some((delay, result)) => {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
result
}
}
}
}
}
fn nodes() -> Vec<String> {
vec!["https://a".into(), "https://b".into(), "https://c".into()]
}
const OK: &str = r#"{"result":42}"#;
fn quick_policy() -> HealthPolicy {
HealthPolicy {
failures_before_cooldown: 2,
api_failures_before_cooldown: 2,
..Default::default()
}
}
#[tokio::test]
async fn health_is_shared_across_clones() {
let client = AsyncNodeClient::new(
FakeTransport::new(vec![
("https://a", Duration::ZERO, Err(Error::Rpc("down".into()))),
("https://b", Duration::ZERO, Ok(OK.into())),
("https://c", Duration::ZERO, Ok(OK.into())),
]),
nodes(),
)
.unwrap()
.with_health_tracking(quick_policy());
client.call("x", serde_json::json!({})).await.unwrap();
client.call("x", serde_json::json!({})).await.unwrap();
let clone = client.clone();
let before = clone.transport.call_count();
clone.call("x", serde_json::json!({})).await.unwrap();
assert_eq!(
clone.transport.call_count() - before,
1,
"the clone must go straight to a healthy node, not rediscover the dead one"
);
assert!(
clone.health().unwrap()[0]
.cooling_methods
.contains(&"x".to_string()),
"the clone must see what the original learned"
);
}
#[tokio::test]
async fn race_prefers_healthy_nodes_over_the_first_ones() {
let client = AsyncNodeClient::new(
FakeTransport::new(vec![
("https://a", Duration::ZERO, Err(Error::Rpc("down".into()))),
("https://b", Duration::from_millis(5), Ok(OK.into())),
("https://c", Duration::from_millis(5), Ok(OK.into())),
]),
nodes(),
)
.unwrap()
.with_health_tracking(quick_policy());
client.call("x", serde_json::json!({})).await.unwrap();
client.call("x", serde_json::json!({})).await.unwrap();
client.transport.calls.lock().unwrap().clear();
client.race("x", serde_json::json!({}), 2).await.unwrap();
let raced = client.transport.calls.lock().unwrap().clone();
assert!(
!raced.contains(&"https://a".to_string()),
"the known-bad node must not take a race slot: {raced:?}"
);
assert_eq!(
raced.len(),
2,
"still races the requested width -- the healthy pair, not one of them: {raced:?}"
);
}
#[tokio::test]
async fn without_health_tracking_the_async_client_is_unchanged() {
let client = AsyncNodeClient::new(
FakeTransport::new(vec![
("https://a", Duration::ZERO, Err(Error::Rpc("down".into()))),
("https://b", Duration::ZERO, Ok(OK.into())),
]),
nodes(),
)
.unwrap();
for _ in 0..3 {
client.call("x", serde_json::json!({})).await.unwrap();
}
assert!(client.health().is_none());
assert_eq!(
client
.transport
.calls
.lock()
.unwrap()
.iter()
.filter(|u| *u == "https://a")
.count(),
3,
"the default must keep trying the dead node first"
);
}
#[tokio::test]
async fn an_empty_node_list_is_refused() {
assert!(AsyncNodeClient::new(FakeTransport::new(vec![]), vec![]).is_err());
}
#[tokio::test]
async fn call_falls_over_to_the_next_node() {
let t = FakeTransport::new(vec![
("https://a", Duration::ZERO, Err(Error::Rpc("down".into()))),
("https://b", Duration::ZERO, Ok(OK.into())),
]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
assert_eq!(client.call("x", serde_json::json!({})).await.unwrap(), 42);
assert_eq!(client.transport.call_count(), 2);
}
#[tokio::test]
async fn call_error_names_every_node_that_failed() {
let t = FakeTransport::new(vec![
(
"https://a",
Duration::ZERO,
Err(Error::Rpc("timeout".into())),
),
(
"https://b",
Duration::ZERO,
Err(Error::Rpc("refused".into())),
),
("https://c", Duration::ZERO, Err(Error::Rpc("503".into()))),
]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
let msg = format!(
"{}",
client.call("x", serde_json::json!({})).await.unwrap_err()
);
for node in ["https://a", "https://b", "https://c"] {
assert!(msg.contains(node), "{msg} should name {node}");
}
}
#[tokio::test(start_paused = true)]
async fn racing_takes_one_timeout_not_the_sum() {
let slow = Duration::from_secs(15);
let t = FakeTransport::new(vec![
("https://a", slow, Err(Error::Rpc("timeout".into()))),
("https://b", slow, Err(Error::Rpc("timeout".into()))),
("https://c", Duration::ZERO, Ok(OK.into())),
]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
let started = Instant::now();
let value = client.race("x", serde_json::json!({}), 3).await.unwrap();
let elapsed = started.elapsed();
assert_eq!(value, 42);
assert!(
elapsed < slow,
"racing took {elapsed:?}, which is no better than waiting for one slow node"
);
assert_eq!(client.transport.call_count(), 3);
}
#[tokio::test(start_paused = true)]
async fn sequential_failover_really_is_the_sum_it_is_claimed_to_be() {
let slow = Duration::from_secs(15);
let t = FakeTransport::new(vec![
("https://a", slow, Err(Error::Rpc("timeout".into()))),
("https://b", slow, Err(Error::Rpc("timeout".into()))),
("https://c", Duration::ZERO, Ok(OK.into())),
]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
let started = Instant::now();
client.call("x", serde_json::json!({})).await.unwrap();
assert!(
started.elapsed() >= slow * 2,
"failover should have waited for both slow nodes"
);
}
#[tokio::test]
async fn racing_reports_every_failure_when_none_answer() {
let t = FakeTransport::new(vec![
(
"https://a",
Duration::ZERO,
Err(Error::Rpc("down-a".into())),
),
(
"https://b",
Duration::ZERO,
Err(Error::Rpc("down-b".into())),
),
]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
let msg = format!(
"{}",
client
.race("x", serde_json::json!({}), 2)
.await
.unwrap_err()
);
assert!(msg.contains("down-a") && msg.contains("down-b"), "{msg}");
assert!(msg.contains("2 raced"), "{msg}");
}
#[tokio::test]
async fn race_width_is_clamped_to_the_node_count() {
let t = FakeTransport::new(vec![("https://a", Duration::ZERO, Ok(OK.into()))]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
assert_eq!(
client.race("x", serde_json::json!({}), 99).await.unwrap(),
42
);
let t = FakeTransport::new(vec![("https://a", Duration::ZERO, Ok(OK.into()))]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
assert_eq!(
client.race("x", serde_json::json!({}), 0).await.unwrap(),
42
);
assert_eq!(client.transport.call_count(), 1);
}
#[tokio::test(start_paused = true)]
async fn retries_use_the_supplied_sleep() {
let t = FakeTransport::new(vec![(
"https://a",
Duration::ZERO,
Err(Error::Rpc("down".into())),
)]);
let client = AsyncNodeClient::new(t, vec!["https://a".into()])
.unwrap()
.with_retries(3, Duration::from_millis(100), |d| tokio::time::sleep(d));
let msg = format!(
"{}",
client.call("x", serde_json::json!({})).await.unwrap_err()
);
assert!(msg.contains("3 pass(es)"), "{msg}");
assert!(msg.contains("retry pass 2"), "{msg}");
assert_eq!(client.transport.call_count(), 3);
}
#[tokio::test]
async fn one_pass_is_the_default_so_a_deadline_is_not_slept_through() {
let t = FakeTransport::new(vec![(
"https://a",
Duration::ZERO,
Err(Error::Rpc("down".into())),
)]);
let client = AsyncNodeClient::new(t, vec!["https://a".into()]).unwrap();
assert!(client.call("x", serde_json::json!({})).await.is_err());
assert_eq!(client.transport.call_count(), 1);
}
#[tokio::test]
async fn concurrent_block_fetch_returns_them_in_order() {
let block = |n: u32| {
format!(
r#"{{"result":{{"block":{{"previous":"{:08x}aabbccdd00000000000000000000abcd",
"timestamp":"2026-08-22T04:00:00","witness":"w",
"transaction_merkle_root":"0000000000000000000000000000000000000000"}}}}}}"#,
n - 1
)
};
let t = FakeTransport::new(vec![("https://a", Duration::ZERO, Ok(block(3)))]);
let client = AsyncNodeClient::new(t, vec!["https://a".into()]).unwrap();
let blocks = client.blocks(10, 14, 3).await.unwrap();
assert_eq!(blocks.len(), 5);
assert!(blocks.iter().all(|b| b.is_some()));
}
#[tokio::test]
async fn a_backwards_block_range_is_refused_before_any_request() {
let t = FakeTransport::new(vec![]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
assert!(client.blocks(10, 5, 2).await.is_err());
assert_eq!(client.transport.call_count(), 0);
}
#[tokio::test]
async fn typed_accessors_parse_the_same_shapes_as_the_blocking_client() {
let account_json = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/account.json"
))
.unwrap();
let t = FakeTransport::new(vec![(
"https://a",
Duration::ZERO,
Ok(format!(r#"{{"result":{account_json}}}"#)),
)]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
let accounts = client.accounts(&["hiveio"]).await.unwrap();
assert!(accounts.iter().any(|a| a.name == "hiveio"));
}
#[tokio::test]
async fn chain_id_mismatch_is_caught() {
let t = FakeTransport::new(vec![(
"https://a",
Duration::ZERO,
Ok(r#"{"result":{"HIVE_CHAIN_ID":"0000000000000000000000000000000000000000000000000000000000000000"}}"#.into()),
)]);
let client = AsyncNodeClient::new(t, nodes()).unwrap();
assert!(client
.verify_chain_id(crate::chains::Chain::Hive)
.await
.is_err());
}
}