v0.2.1-pre.003

This commit is contained in:
2026-08-17 18:55:14 +02:00
parent d3fc0c6d69
commit babe7d9f2b
11 changed files with 1007 additions and 22 deletions

View File

@@ -0,0 +1,274 @@
// file: crates/ksp-onchain-transport-lib/src/client.rs
// version: 1
/// Passive runtime availability reported for one logical HTTP endpoint.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum HttpEndpointAvailability {
/// The endpoint is administratively disabled and cannot be selected.
Disabled,
/// The endpoint is enabled and currently eligible for selection.
Available,
/// The endpoint is enabled but temporarily degraded by runtime observations.
Degraded,
/// The endpoint is enabled but temporarily excluded after provider rate limiting.
RateLimited,
}
/// Safe routing snapshot for one configured endpoint role.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpEndpointRoleSnapshot {
role: std::string::String,
enabled: bool,
request_kinds: std::vec::Vec<std::string::String>,
priority: u32,
}
impl HttpEndpointRoleSnapshot {
/// Returns the logical role name.
#[must_use]
pub fn role(&self) -> &str {
return self.role.as_str();
}
/// Returns whether the role is enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns request-kind descriptors accepted by the role.
#[must_use]
pub fn request_kinds(&self) -> &[std::string::String] {
return self.request_kinds.as_slice();
}
/// Returns the routing priority where lower values are preferred.
#[must_use]
pub const fn priority(&self) -> u32 {
return self.priority;
}
}
/// Safe metadata snapshot for one logical HTTP endpoint.
///
/// Endpoint URLs are intentionally absent because they can contain provider credentials.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpEndpointSnapshot {
name: std::string::String,
provider: std::string::String,
cluster: std::string::String,
enabled: bool,
availability: crate::HttpEndpointAvailability,
roles: std::vec::Vec<crate::HttpEndpointRoleSnapshot>,
}
impl HttpEndpointSnapshot {
/// Returns the configured endpoint identity.
#[must_use]
pub fn name(&self) -> &str {
return self.name.as_str();
}
/// Returns the provider descriptor.
#[must_use]
pub fn provider(&self) -> &str {
return self.provider.as_str();
}
/// Returns the cluster descriptor.
#[must_use]
pub fn cluster(&self) -> &str {
return self.cluster.as_str();
}
/// Returns whether the endpoint is administratively enabled.
#[must_use]
pub const fn enabled(&self) -> bool {
return self.enabled;
}
/// Returns the passive runtime availability.
#[must_use]
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
return self.availability;
}
/// Returns safe role snapshots in declaration order.
#[must_use]
pub fn roles(&self) -> &[crate::HttpEndpointRoleSnapshot] {
return self.roles.as_slice();
}
}
/// Shareable logical HTTP endpoint client owned by KSP Transport.
///
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
#[derive(Clone)]
pub struct HttpEndpointClient {
inner: std::sync::Arc<HttpEndpointClientInner>,
}
struct HttpEndpointClientInner {
settings: crate::HttpEndpointSettings,
_client: reqwest::Client,
availability: std::sync::atomic::AtomicU8,
}
impl HttpEndpointClient {
/// Builds one logical endpoint client from KSP-owned runtime settings.
pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result<Self> {
let validation = crate::settings::validate_endpoint_settings(&settings);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let client_result = build_reqwest_client(&settings);
let client = match client_result {
std::result::Result::Ok(client) => client,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CONNECTION_FAILED, "HTTP endpoint client could not be initialized")
.with_context("endpoint_name", settings.name())
.with_source(error),
);
},
};
let availability = if settings.enabled() { AVAILABILITY_AVAILABLE } else { AVAILABILITY_DISABLED };
ksp_logging_lib::debug!(
target: env!("CARGO_PKG_NAME"),
endpoint_name = settings.name(),
provider = settings.provider().as_str(),
cluster = settings.cluster().as_str(),
enabled = settings.enabled(),
"created logical HTTP endpoint client"
);
return std::result::Result::Ok(Self {
inner: std::sync::Arc::new(HttpEndpointClientInner { settings, _client: client, availability: std::sync::atomic::AtomicU8::new(availability) }),
});
}
/// Returns the endpoint identity used for safe diagnostics and routing.
#[must_use]
pub fn name(&self) -> &str {
return self.inner.settings.name();
}
/// Returns the provider descriptor.
#[must_use]
pub fn provider(&self) -> &crate::HttpProviderName {
return self.inner.settings.provider();
}
/// Returns the cluster descriptor.
#[must_use]
pub fn cluster(&self) -> &crate::HttpClusterName {
return self.inner.settings.cluster();
}
/// Returns whether the endpoint is administratively enabled.
#[must_use]
pub fn enabled(&self) -> bool {
return self.inner.settings.enabled();
}
/// Returns whether one enabled role can serve the requested capability.
#[must_use]
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
return self.matching_role(role, request_kind).is_some();
}
/// Returns a safe endpoint snapshot with no URL or provider credential material.
#[must_use]
pub fn snapshot(&self) -> crate::HttpEndpointSnapshot {
let mut roles = std::vec::Vec::with_capacity(self.inner.settings.roles().len());
for role in self.inner.settings.roles() {
let mut request_kinds = std::vec::Vec::with_capacity(role.request_kinds().len());
for request_kind in role.request_kinds() {
request_kinds.push(request_kind.as_str().to_owned());
}
roles.push(crate::HttpEndpointRoleSnapshot {
role: role.role().as_str().to_owned(),
enabled: role.enabled(),
request_kinds,
priority: role.priority(),
});
}
return crate::HttpEndpointSnapshot {
name: self.name().to_owned(),
provider: self.provider().as_str().to_owned(),
cluster: self.cluster().as_str().to_owned(),
enabled: self.enabled(),
availability: self.availability(),
roles,
};
}
pub(crate) fn matching_role<'a>(
&'a self,
role: &crate::HttpRoleName,
request_kind: &crate::HttpRequestKind,
) -> std::option::Option<&'a crate::HttpEndpointRoleSettings> {
if !self.is_selectable() {
return std::option::Option::None;
}
for candidate_role in self.inner.settings.roles() {
if !candidate_role.enabled() || candidate_role.role() != role {
continue;
}
for capability in candidate_role.request_kinds() {
if capability.is_wildcard() || capability == request_kind {
return std::option::Option::Some(candidate_role);
}
}
}
return std::option::Option::None;
}
fn is_selectable(&self) -> bool {
return self.enabled() && self.availability() == crate::HttpEndpointAvailability::Available;
}
fn availability(&self) -> crate::HttpEndpointAvailability {
return availability_from_code(self.inner.availability.load(std::sync::atomic::Ordering::Relaxed));
}
}
impl std::fmt::Debug for HttpEndpointClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("HttpEndpointClient").field("snapshot", &self.snapshot()).finish();
}
}
fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result<reqwest::Client, reqwest::Error> {
let mut builder = reqwest::Client::builder()
.connect_timeout(settings.connect_timeout())
.timeout(settings.request_timeout())
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")));
if let std::option::Option::Some(max_idle) = settings.max_idle_connections_per_host() {
builder = builder.pool_max_idle_per_host(max_idle);
}
return builder.build();
}
const AVAILABILITY_DISABLED: u8 = 0;
const AVAILABILITY_AVAILABLE: u8 = 1;
const AVAILABILITY_DEGRADED: u8 = 2;
const AVAILABILITY_RATE_LIMITED: u8 = 3;
fn availability_from_code(code: u8) -> crate::HttpEndpointAvailability {
if code == AVAILABILITY_DISABLED {
return crate::HttpEndpointAvailability::Disabled;
}
if code == AVAILABILITY_DEGRADED {
return crate::HttpEndpointAvailability::Degraded;
}
if code == AVAILABILITY_RATE_LIMITED {
return crate::HttpEndpointAvailability::RateLimited;
}
return crate::HttpEndpointAvailability::Available;
}
#[cfg(test)]
#[path = "../unit_tests/client.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 1
// version: 2
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -7,14 +7,25 @@
//! KSP-owned Solana on-chain transport foundation.
//!
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter, while network
//! clients, pools, resilience and typed Solana method adapters are introduced by subsequent `0.2.1` prereleases.
//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter. Logical endpoint
//! clients and priority-aware pools are now available, while resilience limits and typed Solana method adapters remain staged by subsequent `0.2.1`
//! prereleases.
mod client;
mod error;
mod json_rpc;
mod pool;
mod rpc_method;
mod settings;
/// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability;
/// Shareable logical HTTP endpoint client owned by KSP Transport.
pub use self::client::HttpEndpointClient;
/// Safe routing snapshot for one configured endpoint role.
pub use self::client::HttpEndpointRoleSnapshot;
/// Safe metadata snapshot for one logical HTTP endpoint.
pub use self::client::HttpEndpointSnapshot;
/// Error code used when no logical endpoint can satisfy a request.
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
/// Error code used when an HTTP connection cannot be established.
@@ -53,6 +64,12 @@ pub use self::json_rpc::JsonRpcSuccessResponse;
pub use self::json_rpc::parse_json_rpc_response_text;
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
pub use self::json_rpc::parse_json_rpc_response_value;
/// Result of one logical endpoint selection.
pub use self::pool::HttpEndpointSelection;
/// Shareable logical HTTP endpoint pool with priority routing and round-robin fairness.
pub use self::pool::HttpTransportPool;
/// Safe snapshot of the logical HTTP endpoint pool.
pub use self::pool::HttpTransportPoolSnapshot;
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
pub use self::rpc_method::HttpRpcCategory;
/// Release that owns typed KSP coverage for one audited HTTP RPC method.

View File

@@ -0,0 +1,215 @@
// file: crates/ksp-onchain-transport-lib/src/pool.rs
// version: 1
/// Safe snapshot of the logical HTTP endpoint pool.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HttpTransportPoolSnapshot {
endpoints: std::vec::Vec<crate::HttpEndpointSnapshot>,
}
impl HttpTransportPoolSnapshot {
/// Returns safe endpoint snapshots in configured declaration order.
#[must_use]
pub fn endpoints(&self) -> &[crate::HttpEndpointSnapshot] {
return self.endpoints.as_slice();
}
/// Returns the total number of configured logical endpoints.
#[must_use]
pub fn endpoint_count(&self) -> usize {
return self.endpoints.len();
}
/// Returns the number of endpoints currently eligible for routing.
#[must_use]
pub fn available_endpoint_count(&self) -> usize {
return self.endpoints.iter().filter(|endpoint| endpoint.availability() == crate::HttpEndpointAvailability::Available).count();
}
}
/// Result of one logical endpoint selection.
#[derive(Clone, Debug)]
pub struct HttpEndpointSelection {
client: crate::HttpEndpointClient,
role: crate::HttpRoleName,
request_kind: crate::HttpRequestKind,
priority: u32,
}
impl HttpEndpointSelection {
/// Returns the selected endpoint client.
#[must_use]
pub const fn client(&self) -> &crate::HttpEndpointClient {
return &self.client;
}
/// Returns the selected endpoint identity.
#[must_use]
pub fn endpoint_name(&self) -> &str {
return self.client.name();
}
/// Returns the matched logical role.
#[must_use]
pub const fn role(&self) -> &crate::HttpRoleName {
return &self.role;
}
/// Returns the matched request-kind capability.
#[must_use]
pub const fn request_kind(&self) -> &crate::HttpRequestKind {
return &self.request_kind;
}
/// Returns the selected role priority where lower values are preferred.
#[must_use]
pub const fn priority(&self) -> u32 {
return self.priority;
}
}
/// Shareable logical HTTP endpoint pool with priority routing and per-capability round-robin fairness.
#[derive(Clone)]
pub struct HttpTransportPool {
inner: std::sync::Arc<HttpTransportPoolInner>,
}
struct HttpTransportPoolInner {
clients: std::vec::Vec<crate::HttpEndpointClient>,
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
}
impl HttpTransportPool {
/// Builds a logical endpoint pool after validating all Transport-owned runtime settings.
pub fn new(settings: crate::HttpTransportSettings) -> ksp_core_lib::Result<Self> {
let validation = settings.validate();
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let mut clients = std::vec::Vec::with_capacity(settings.endpoints().len());
for endpoint in settings.endpoints() {
let client_result = crate::HttpEndpointClient::new(endpoint.clone());
let client = match client_result {
std::result::Result::Ok(client) => client,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
clients.push(client);
}
let pool = Self {
inner: std::sync::Arc::new(HttpTransportPoolInner { clients, cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()) }),
};
ksp_logging_lib::debug!(
target: env!("CARGO_PKG_NAME"),
endpoint_count = pool.inner.clients.len(),
available_endpoint_count = pool.snapshot().available_endpoint_count(),
"created logical HTTP endpoint pool"
);
return std::result::Result::Ok(pool);
}
/// Selects an endpoint for one standard audited RPC method.
pub fn select_for_method(&self, role: &crate::HttpRoleName, method: &crate::HttpRpcMethodDescriptor) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
let support = method.ensure_runtime_supported();
if let std::result::Result::Err(error) = support {
return std::result::Result::Err(error);
}
return self.select_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind()));
}
/// Selects an endpoint for an open request-kind descriptor, including provider extensions.
pub fn select_for_request_kind(
&self,
role: &crate::HttpRoleName,
request_kind: &crate::HttpRequestKind,
) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
let mut candidates = std::vec::Vec::new();
for (client_index, client) in self.inner.clients.iter().enumerate() {
let matching_role = client.matching_role(role, request_kind);
if let std::option::Option::Some(matching_role) = matching_role {
candidates.push(PoolCandidate { client_index, priority: matching_role.priority() });
}
}
if candidates.is_empty() {
return selection_failed(role, request_kind);
}
candidates.sort_by_key(|candidate| candidate.priority);
let first_candidate = candidates.first();
let best_priority = match first_candidate {
std::option::Option::Some(candidate) => candidate.priority,
std::option::Option::None => return selection_failed(role, request_kind),
};
let best_tier: std::vec::Vec<PoolCandidate> = candidates.into_iter().take_while(|candidate| candidate.priority == best_priority).collect();
let selected_position = self.next_position(role, request_kind, best_priority, best_tier.len());
let selected = match best_tier.get(selected_position) {
std::option::Option::Some(selected) => selected,
std::option::Option::None => return selection_failed(role, request_kind),
};
let selected_client = self.inner.clients.get(selected.client_index);
let client = match selected_client {
std::option::Option::Some(client) => client.clone(),
std::option::Option::None => return selection_failed(role, request_kind),
};
ksp_logging_lib::debug!(
target: env!("CARGO_PKG_NAME"),
endpoint_name = client.name(),
role = role.as_str(),
request_kind = request_kind.as_str(),
priority = best_priority,
tier_size = best_tier.len(),
"selected logical HTTP endpoint"
);
return std::result::Result::Ok(crate::HttpEndpointSelection {
client,
role: role.clone(),
request_kind: request_kind.clone(),
priority: best_priority,
});
}
/// Returns a safe pool snapshot without endpoint URLs.
#[must_use]
pub fn snapshot(&self) -> crate::HttpTransportPoolSnapshot {
let endpoints = self.inner.clients.iter().map(crate::HttpEndpointClient::snapshot).collect();
return crate::HttpTransportPoolSnapshot { endpoints };
}
fn next_position(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, priority: u32, tier_size: usize) -> usize {
let key = (role.as_str().to_owned(), request_kind.as_str().to_owned(), priority);
let lock_result = self.inner.cursors.lock();
let mut cursors = match lock_result {
std::result::Result::Ok(cursors) => cursors,
std::result::Result::Err(poisoned) => poisoned.into_inner(),
};
let cursor = cursors.entry(key).or_insert(0);
if tier_size == 0 {
return 0;
}
let selected = *cursor % tier_size;
*cursor = (*cursor).wrapping_add(1);
return selected;
}
}
impl std::fmt::Debug for HttpTransportPool {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.debug_struct("HttpTransportPool").field("snapshot", &self.snapshot()).finish();
}
}
#[derive(Clone, Copy)]
struct PoolCandidate {
client_index: usize,
priority: u32,
}
fn selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<crate::HttpEndpointSelection> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED, "no HTTP endpoint can satisfy the requested role and request kind")
.with_context("role", role.as_str())
.with_context("request_kind", request_kind.as_str()),
);
}
#[cfg(test)]
#[path = "../unit_tests/pool.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/settings.rs
// version: 2
// version: 3
/// Runtime HTTP endpoint URL owned by Transport.
///
@@ -443,6 +443,10 @@ impl HttpTransportSettings {
}
}
pub(crate) fn validate_endpoint_settings(endpoint: &crate::HttpEndpointSettings) -> ksp_core_lib::Result<()> {
return validate_endpoint(endpoint, 0);
}
fn validate_retry(retry: &crate::HttpRetrySettings) -> ksp_core_lib::Result<()> {
if retry.initial_backoff().is_zero() {
return invalid_settings("initial retry backoff must be greater than zero", "retry.initial_backoff");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 2
// version: 3
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -61,3 +61,43 @@ fn public_error_codes_share_the_core_error_domain() {
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS.domain(), "onchain_transport");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_RPC_APPLICATION_ERROR.domain(), "onchain_transport");
}
#[test]
fn public_pool_contract_selects_a_standard_method_without_exposing_url() {
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=SECRET-CANARY")
.expect("public URL parser must accept HTTPS endpoint");
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
ksp_onchain_transport_lib::HttpRoleName::new("default"),
true,
std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")],
10,
ksp_onchain_transport_lib::HttpRoleLimits::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
),
);
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
"primary",
true,
ksp_onchain_transport_lib::HttpProviderName::new("provider"),
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(2),
std::time::Duration::from_secs(10),
std::option::Option::Some(8),
std::vec![role],
);
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
std::vec![endpoint],
ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
);
let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed");
let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist");
let selected = pool.select_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method).expect("public pool must route audited method");
assert_eq!(selected.endpoint_name(), "primary");
let rendered = format!("{pool:?}");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
}

View File

@@ -0,0 +1,49 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs
// version: 1
fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings {
let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse");
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
return crate::HttpEndpointSettings::new(
"endpoint",
enabled,
crate::HttpProviderName::new("provider"),
crate::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(2),
std::option::Option::Some(4),
std::vec![role],
);
}
#[test]
fn endpoint_client_snapshot_never_contains_url_or_secret_material() {
let client = super::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build");
let snapshot = client.snapshot();
let rendered = format!("{snapshot:?} {client:?}");
assert_eq!(snapshot.availability(), crate::HttpEndpointAvailability::Available);
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(!rendered.contains("https://"));
}
#[test]
fn disabled_endpoint_client_is_visible_but_not_selectable() {
let client = super::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build");
assert_eq!(client.snapshot().availability(), crate::HttpEndpointAvailability::Disabled);
assert!(!client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
}
#[test]
fn endpoint_client_matches_exact_and_wildcard_capabilities() {
let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
assert!(client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")));
assert!(!client.supports(&crate::HttpRoleName::new("write"), &crate::HttpRequestKind::new("get_balance")));
}

View File

@@ -0,0 +1,167 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs
// version: 1
fn role(name: &str, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointRoleSettings {
return crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new(name),
true,
request_kinds,
priority,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
}
fn endpoint(name: &str, enabled: bool, priority: u32, request_kinds: std::vec::Vec<crate::HttpRequestKind>) -> crate::HttpEndpointSettings {
return crate::HttpEndpointSettings::new(
name,
enabled,
crate::HttpProviderName::new("provider"),
crate::HttpClusterName::new("devnet"),
crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"),
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(2),
std::option::Option::Some(4),
std::vec![role("default", priority, request_kinds)],
);
}
fn settings(endpoints: std::vec::Vec<crate::HttpEndpointSettings>) -> crate::HttpTransportSettings {
return crate::HttpTransportSettings::new(
endpoints,
crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)),
);
}
#[test]
fn pool_prefers_lowest_priority_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
endpoint("secondary", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("primary", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
]))
.expect("pool must build");
let selection = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
.expect("selection must succeed");
assert_eq!(selection.endpoint_name(), "primary");
assert_eq!(selection.priority(), 10);
}
#[test]
fn pool_round_robins_fairly_inside_best_priority_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
endpoint("one", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("two", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let first = pool.select_for_request_kind(&role, &kind).expect("first selection must succeed");
let second = pool.select_for_request_kind(&role, &kind).expect("second selection must succeed");
let third = pool.select_for_request_kind(&role, &kind).expect("third selection must succeed");
assert_eq!(first.endpoint_name(), "one");
assert_eq!(second.endpoint_name(), "two");
assert_eq!(third.endpoint_name(), "one");
}
#[test]
fn disabled_best_priority_endpoint_falls_back_to_next_tier() {
let pool = super::HttpTransportPool::new(settings(std::vec![
endpoint("disabled-primary", false, 1, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
]))
.expect("pool must build");
let selection = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
.expect("fallback must be selected");
assert_eq!(selection.endpoint_name(), "fallback");
}
#[test]
fn pool_filters_role_and_capability_before_priority() {
let pool = super::HttpTransportPool::new(settings(std::vec![
endpoint("wrong-capability", true, 1, std::vec![crate::HttpRequestKind::new("send_transaction")]),
endpoint("matching", true, 50, std::vec![crate::HttpRequestKind::new("get_balance")]),
]))
.expect("pool must build");
let selection = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
.expect("matching capability must be selected");
assert_eq!(selection.endpoint_name(), "matching");
}
#[test]
fn pool_returns_structured_error_when_no_endpoint_matches() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
.expect("pool must build");
let error = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("send_transaction"))
.expect_err("unsupported request kind must fail selection");
assert_eq!(error.code(), crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED);
assert!(!format!("{error:?}").contains("SECRET-CANARY"));
}
#[test]
fn standard_method_selection_uses_registry_request_kind() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)]))
.expect("pool must build");
let method = crate::find_http_rpc_method("getBalance").expect("audited method must exist");
let selection = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect("standard method must route");
assert_eq!(selection.request_kind().as_str(), "get_balance");
}
#[test]
fn pool_snapshot_is_safe_and_preserves_disabled_endpoints() {
let pool = super::HttpTransportPool::new(settings(std::vec![
endpoint("enabled", true, 10, std::vec![crate::HttpRequestKind::wildcard()]),
endpoint("disabled", false, 10, std::vec![crate::HttpRequestKind::wildcard()]),
]))
.expect("pool must build");
let snapshot = pool.snapshot();
let rendered = format!("{snapshot:?} {pool:?}");
assert_eq!(snapshot.endpoint_count(), 2);
assert_eq!(snapshot.available_endpoint_count(), 1);
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains(".invalid/rpc"));
}
#[test]
fn disabled_role_is_excluded_before_priority_selection() {
let base = endpoint("disabled-role", true, 1, std::vec![crate::HttpRequestKind::wildcard()]);
let disabled_role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
false,
std::vec![crate::HttpRequestKind::wildcard()],
1,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let disabled_role_endpoint = crate::HttpEndpointSettings::new(
base.name(),
true,
base.provider().clone(),
base.cluster().clone(),
base.url().clone(),
base.connect_timeout(),
base.request_timeout(),
base.max_idle_connections_per_host(),
std::vec![disabled_role],
);
let pool = super::HttpTransportPool::new(settings(std::vec![
disabled_role_endpoint,
endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]),
]))
.expect("pool must build");
let selection = pool
.select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))
.expect("enabled fallback role must be selected");
assert_eq!(selection.endpoint_name(), "fallback");
}
#[test]
fn removed_standard_method_is_rejected_before_endpoint_routing() {
let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)]))
.expect("pool must build");
let method = crate::find_http_rpc_method("confirmTransaction").expect("historical method must exist");
let error = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect_err("removed standard method must be rejected before routing");
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
}