v0.2.9-pre.006

This commit is contained in:
2026-08-24 13:17:24 +02:00
parent 15e3402e1c
commit b32de48c0b
16 changed files with 1299 additions and 115 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/client.rs
// version: 7
// file: crates/ksp-onchain-transport-lib/src/http_client.rs
// version: 8
/// Passive runtime availability reported for one logical HTTP endpoint or role.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -510,5 +510,5 @@ fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::
}
#[cfg(test)]
#[path = "../unit_tests/client.rs"]
#[path = "../unit_tests/http_client.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/executor.rs
// version: 3
// file: crates/ksp-onchain-transport-lib/src/http_executor.rs
// version: 4
const HTTP_BAD_GATEWAY: u16 = 502;
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
@@ -228,5 +228,5 @@ fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ks
}
#[cfg(test)]
#[path = "../unit_tests/executor.rs"]
#[path = "../unit_tests/http_executor.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/pool.rs
// version: 7
// file: crates/ksp-onchain-transport-lib/src/http_pool.rs
// version: 8
/// Safe snapshot of the logical HTTP endpoint pool.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -616,5 +616,5 @@ fn request_timeout(role: &crate::HttpRoleName, request_kind: &crate::HttpRequest
}
#[cfg(test)]
#[path = "../unit_tests/pool.rs"]
#[path = "../unit_tests/http_pool.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/resilience.rs
// version: 3
// file: crates/ksp-onchain-transport-lib/src/http_resilience.rs
// version: 4
const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1);
const MAX_PROVIDER_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(60);
@@ -401,5 +401,5 @@ fn retry_backoff(settings: &crate::HttpRetrySettings, retry_number: u32) -> std:
}
#[cfg(test)]
#[path = "../unit_tests/resilience.rs"]
#[path = "../unit_tests/http_resilience.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/settings.rs
// version: 6
// file: crates/ksp-onchain-transport-lib/src/http_settings.rs
// version: 7
/// Runtime HTTP endpoint URL owned by Transport.
///
@@ -582,5 +582,5 @@ fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> {
}
#[cfg(test)]
#[path = "../unit_tests/settings.rs"]
#[path = "../unit_tests/http_settings.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 38
// version: 39
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -36,19 +36,21 @@
//! `0.2.9-pre.003` adds bounded TLS/WebPKI connection establishment, generic redacted ASCII request metadata and the seven standard Yellowstone unary RPCs
//! through KSP-owned DTOs.
//! `0.2.9-pre.004` materializes the provider-neutral standard `SubscribeRequest` foundation: all seven named filter maps, global filter-name bounds/uniqueness,
//! commitment, ordered account-data slices, ping and `from_slot`. Family-specific account/slot/transaction/block filter fields remain staged for `pre.005007`.
//! commitment, ordered account-data slices, ping and `from_slot`. Family-specific account/slot filters land in `pre.005`; transaction/block filters remain staged for `pre.007008`.
//! `0.2.9-pre.006` normalizes the five unambiguously HTTP-owned private implementation modules with an `http_` prefix while preserving shared `rpc_*`, JSON-RPC, error and constants modules.
mod client;
mod constants;
mod error;
mod executor;
mod grpc_channel;
mod grpc_settings;
mod grpc_subscribe;
mod grpc_unary;
mod http_client;
mod http_executor;
mod http_pool;
mod http_resilience;
mod http_settings;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_accounts;
mod rpc_blocks;
mod rpc_canary;
@@ -58,7 +60,6 @@ mod rpc_economics;
mod rpc_method;
mod rpc_tokens;
mod rpc_transactions;
mod settings;
mod ws_accounts;
mod ws_blocks;
mod ws_cluster;
@@ -71,13 +72,13 @@ mod ws_subscription;
mod ws_transactions;
/// Passive runtime availability reported for one logical HTTP endpoint.
pub use self::client::HttpEndpointAvailability;
pub use self::http_client::HttpEndpointAvailability;
/// Shareable logical HTTP endpoint client owned by KSP Transport.
pub use self::client::HttpEndpointClient;
pub use self::http_client::HttpEndpointClient;
/// Safe routing snapshot for one configured endpoint role.
pub use self::client::HttpEndpointRoleSnapshot;
pub use self::http_client::HttpEndpointRoleSnapshot;
/// Safe metadata snapshot for one logical HTTP endpoint.
pub use self::client::HttpEndpointSnapshot;
pub use self::http_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 a Yellowstone gRPC channel cannot be prepared safely.
@@ -134,28 +135,24 @@ pub use self::grpc_settings::YellowstoneGrpcReconnectSettings;
pub use self::grpc_settings::YellowstoneGrpcSessionSettings;
/// Complete runtime settings consumed by the KSP Yellowstone gRPC transport engine.
pub use self::grpc_settings::YellowstoneGrpcTransportSettings;
/// One validated standard Yellowstone account predicate.
pub use self::grpc_subscribe::YellowstoneAccountFilterPredicate;
/// Typed account payload carried by one standard Yellowstone account update.
pub use self::grpc_subscribe::YellowstoneAccountInfo;
/// Lamport comparison used by standard Yellowstone account filters.
pub use self::grpc_subscribe::YellowstoneAccountLamportsFilter;
/// One validated standard Yellowstone account predicate.
pub use self::grpc_subscribe::YellowstoneAccountFilterPredicate;
/// Standard Yellowstone account-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneAccountUpdate;
/// Validated standard Yellowstone account memcmp predicate.
pub use self::grpc_subscribe::YellowstoneAccountMemcmp;
/// Encoding selected by one Yellowstone account memcmp predicate.
pub use self::grpc_subscribe::YellowstoneAccountMemcmpEncoding;
/// Standard Yellowstone account-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneAccountUpdate;
/// Lamport comparison used by standard Yellowstone account filters.
pub use self::grpc_subscribe::YellowstoneAccountLamportsFilter;
/// One standard Yellowstone account-data slice.
pub use self::grpc_subscribe::YellowstoneAccountsDataSlice;
/// Wire-preserving KSP representation of a standard Yellowstone Cuckoo filter.
pub use self::grpc_subscribe::YellowstoneCuckooFilter;
/// Hash algorithm carried by a standard Yellowstone Cuckoo filter.
pub use self::grpc_subscribe::YellowstoneCuckooHashAlgorithm;
/// Current standard Yellowstone slot status.
pub use self::grpc_subscribe::YellowstoneSlotStatus;
/// Standard Yellowstone slot-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneSlotUpdate;
/// Wire-preserving KSP representation of a standard Yellowstone Cuckoo filter.
pub use self::grpc_subscribe::YellowstoneCuckooFilter;
/// Complete account-family filter group for standard Yellowstone Subscribe.
pub use self::grpc_subscribe::YellowstoneSubscribeAccountFilter;
/// Block-family filter-group shell for standard Yellowstone Subscribe.
@@ -172,12 +169,16 @@ pub use self::grpc_subscribe::YellowstoneSubscribePing;
pub use self::grpc_subscribe::YellowstoneSubscribeRequest;
/// Complete slot-family filter group for standard Yellowstone Subscribe.
pub use self::grpc_subscribe::YellowstoneSubscribeSlotFilter;
/// Transaction-family filter-group shell shared by transactions and transaction-status maps.
pub use self::grpc_subscribe::YellowstoneSubscribeTransactionFilter;
/// Current standard Yellowstone slot status.
pub use self::grpc_subscribe::YellowstoneSlotStatus;
/// Standard Yellowstone slot-update projection owned by KSP.
pub use self::grpc_subscribe::YellowstoneSlotUpdate;
/// Fixed-width transaction signature attached to Yellowstone account updates when available.
pub use self::grpc_subscribe::YellowstoneTransactionSignature;
/// Timestamp attached to standard Yellowstone update envelopes.
pub use self::grpc_subscribe::YellowstoneUpdateTimestamp;
/// Transaction-family filter-group shell shared by transactions and transaction-status maps.
pub use self::grpc_subscribe::YellowstoneSubscribeTransactionFilter;
/// Standard Solana Yellowstone unary facade over one KSP-owned physical gRPC channel.
pub use self::grpc_unary::SolanaYellowstoneGrpcUnaryClient;
/// Block height returned by the standard Yellowstone unary surface.
@@ -209,21 +210,21 @@ 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;
pub use self::http_pool::HttpEndpointSelection;
/// Runtime admission permit for one HTTP request.
pub use self::pool::HttpRequestPermit;
pub use self::http_pool::HttpRequestPermit;
/// Shareable logical HTTP endpoint pool with priority routing, admission limits and bounded deadlines.
pub use self::pool::HttpTransportPool;
pub use self::http_pool::HttpTransportPool;
/// Safe snapshot of the logical HTTP endpoint pool.
pub use self::pool::HttpTransportPoolSnapshot;
pub use self::http_pool::HttpTransportPoolSnapshot;
/// Dispatch knowledge used to prevent ambiguous automatic resubmission.
pub use self::resilience::HttpDispatchState;
pub use self::http_resilience::HttpDispatchState;
/// Transport-level cause considered by the bounded retry policy.
pub use self::resilience::HttpRetryCause;
pub use self::http_resilience::HttpRetryCause;
/// Result of evaluating one bounded transport retry opportunity.
pub use self::resilience::HttpRetryDecision;
pub use self::http_resilience::HttpRetryDecision;
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
pub use self::resilience::evaluate_transport_retry;
pub use self::http_resilience::evaluate_transport_retry;
/// Typed transport-level Solana account without Program/SPL decoding.
pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`.
@@ -397,25 +398,25 @@ pub use self::rpc_transactions::SolanaTransactionVersion;
/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`.
pub use self::rpc_transactions::SolanaWireField;
/// Open cluster or network descriptor used by HTTP endpoint settings.
pub use self::settings::HttpClusterName;
pub use self::http_settings::HttpClusterName;
/// Runtime settings for one role declared by an HTTP endpoint.
pub use self::settings::HttpEndpointRoleSettings;
pub use self::http_settings::HttpEndpointRoleSettings;
/// Runtime settings for one named Solana HTTP endpoint.
pub use self::settings::HttpEndpointSettings;
pub use self::http_settings::HttpEndpointSettings;
/// Runtime HTTP endpoint URL with redacted diagnostics.
pub use self::settings::HttpEndpointUrl;
pub use self::http_settings::HttpEndpointUrl;
/// Open provider descriptor used by HTTP endpoint settings.
pub use self::settings::HttpProviderName;
pub use self::http_settings::HttpProviderName;
/// Open request-kind descriptor used by logical endpoint capabilities.
pub use self::settings::HttpRequestKind;
pub use self::http_settings::HttpRequestKind;
/// Bounded retry settings owned by the HTTP transport runtime.
pub use self::settings::HttpRetrySettings;
pub use self::http_settings::HttpRetrySettings;
/// Local limits attached to one logical HTTP endpoint role.
pub use self::settings::HttpRoleLimits;
pub use self::http_settings::HttpRoleLimits;
/// Open logical endpoint role descriptor.
pub use self::settings::HttpRoleName;
pub use self::http_settings::HttpRoleName;
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
pub use self::settings::HttpTransportSettings;
pub use self::http_settings::HttpTransportSettings;
/// Configuration accepted by the standard Solana `accountSubscribe` WebSocket method.
pub use self::ws_accounts::SolanaAccountSubscribeConfig;
/// One `programNotification` payload preserving contextual and non-contextual upstream forms.
@@ -504,17 +505,17 @@ pub use self::ws_transactions::SolanaSignatureSubscribeConfig;
/// Owning tracing target for events emitted by the on-chain transport crate.
pub(crate) use self::constants::TRACING_TARGET;
/// Crate-internal `HttpConcurrencyPermit` state shared across the owning crate.
pub(crate) use self::resilience::HttpConcurrencyPermit;
pub(crate) use self::http_resilience::HttpConcurrencyPermit;
/// Crate-internal `HttpRoleRuntime` state shared across the owning crate.
pub(crate) use self::resilience::HttpRoleRuntime;
pub(crate) use self::http_resilience::HttpRoleRuntime;
/// Crate-internal `RoleAdmissionAttempt` variants used by the owning crate.
pub(crate) use self::resilience::RoleAdmissionAttempt;
pub(crate) use self::http_resilience::RoleAdmissionAttempt;
/// Decodes one private serde wire type into the shared Transport error domain for typed RPC adapters.
pub(crate) use self::rpc_common::decode_wire_json;
/// Parses a base58 public key without echoing its wire value into diagnostics for typed RPC adapters.
pub(crate) use self::rpc_common::parse_wire_pubkey;
/// Validates endpoint settings.
pub(crate) use self::settings::validate_endpoint_settings;
pub(crate) use self::http_settings::validate_endpoint_settings;
/// Crate-internal command surface shared by the physical session and typed subscription handle.
pub(crate) use self::ws_session::WsSessionCommand;
/// Crate-internal notification dispatch result.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 36
// version: 37
//! Release-level completeness canaries for staged HTTP and WebSocket Transport coverage.
@@ -1071,19 +1071,7 @@ fn release_v0_2_9_pre_003_adds_tls_metadata_and_exactly_seven_standard_unary_met
fn release_v0_2_9_pre_004_materializes_only_standard_subscribe_common_contract() {
let source = include_str!("../src/grpc_subscribe.rs");
let crate_root = include_str!("../src/lib.rs");
for wire_field in [
"accounts:",
"slots:",
"transactions:",
"transactions_status:",
"blocks:",
"blocks_meta:",
"entry:",
"commitment:",
"accounts_data_slice:",
"ping:",
"from_slot:",
] {
for wire_field in ["accounts:", "slots:", "transactions:", "transactions_status:", "blocks:", "blocks_meta:", "entry:", "commitment:", "accounts_data_slice:", "ping:", "from_slot:"] {
assert!(source.contains(wire_field), "missing standard Yellowstone SubscribeRequest field: {wire_field}");
}
assert!(source.contains("MAX_GRPC_SUBSCRIBE_FILTER_GROUP_COUNT"));
@@ -1155,3 +1143,28 @@ fn release_v0_2_9_pre_005_completes_standard_accounts_and_slots_without_advancin
let _account = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneAccountUpdate>();
let _slot = std::any::type_name::<ksp_onchain_transport_lib::YellowstoneSlotUpdate>();
}
#[test]
fn release_v0_2_9_pre_006_namespaces_unambiguously_http_owned_private_modules() {
let crate_root = include_str!("../src/lib.rs");
let http_client = include_str!("../src/http_client.rs");
let http_executor = include_str!("../src/http_executor.rs");
let http_pool = include_str!("../src/http_pool.rs");
let http_resilience = include_str!("../src/http_resilience.rs");
let http_settings = include_str!("../src/http_settings.rs");
for declaration in ["mod http_client;", "mod http_executor;", "mod http_pool;", "mod http_resilience;", "mod http_settings;"] {
assert!(crate_root.contains(declaration), "missing HTTP-owned private module declaration: {declaration}");
}
for historical in ["mod client;", "mod executor;", "mod pool;", "mod resilience;", "mod settings;"] {
assert!(!crate_root.contains(historical), "historical ambiguous private module declaration remains: {historical}");
}
assert!(http_client.contains("HttpEndpointClient"));
assert!(http_executor.contains("execute_standard_rpc"));
assert!(http_pool.contains("HttpTransportPool"));
assert!(http_resilience.contains("HttpRetryDecision"));
assert!(http_settings.contains("HttpTransportSettings"));
// Shared/protocol-oriented modules deliberately keep their existing names.
for shared in ["mod constants;", "mod error;", "mod json_rpc;", "mod rpc_common;", "mod rpc_accounts;", "mod rpc_blocks;", "mod rpc_transactions;"] {
assert!(crate_root.contains(shared), "shared/protocol module was incorrectly HTTP-prefixed: {shared}");
}
}

View File

@@ -0,0 +1,62 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/http_client.rs
// version: 4
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 = crate::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 = crate::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 = crate::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")));
}
#[test]
fn endpoint_role_snapshot_exposes_safe_resilience_state() {
let client = crate::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build");
let snapshot = client.snapshot();
let role = &snapshot.roles()[0];
assert_eq!(role.availability(), crate::HttpEndpointAvailability::Available);
assert_eq!(role.in_flight_requests(), std::option::Option::None);
assert_eq!(role.cooldown_remaining(), std::option::Option::None);
assert_eq!(role.success_count(), 0);
assert_eq!(role.failure_count(), 0);
assert_eq!(role.rate_limit_count(), 0);
}

View File

@@ -0,0 +1,141 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/http_executor.rs
// version: 3
fn pool_for_url(url: &str, request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
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::Some(std::time::Duration::from_millis(1)),
),
);
let endpoint = crate::HttpEndpointSettings::new(
"fixture",
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_millis(100),
request_timeout,
std::option::Option::Some(1),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
fn health_method() -> &'static crate::HttpRpcMethodDescriptor {
return crate::find_http_rpc_method("getHealth").expect("getHealth descriptor must exist");
}
fn serve_rate_limit_then_success() -> (std::string::String, std::thread::JoinHandle<usize>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let mut count = 0_usize;
while count < 2 {
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
let _ = read_request(&mut stream);
let response = if count == 0 {
"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned()
} else {
let body = include_str!("../fixtures/http/get_health.success.json");
format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body)
};
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
count = count.saturating_add(1);
}
return count;
});
return (format!("http://{address}"), handle);
}
fn serve_timeout() -> (std::string::String, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept request");
let _ = read_request(&mut stream);
std::thread::sleep(std::time::Duration::from_millis(100));
return;
});
return (format!("http://{address}"), handle);
}
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if request_complete(bytes.as_slice()) {
break;
}
}
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
}
fn request_complete(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(_) => return false,
};
let header_end = match text.find("\r\n\r\n") {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
let mut content_length = 0_usize;
for line in text[..header_end].lines() {
let (name, value) = match line.split_once(':') {
std::option::Option::Some(parts) => parts,
std::option::Option::None => continue,
};
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse::<usize>().expect("content length must parse");
}
}
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
}
#[tokio::test(flavor = "current_thread")]
async fn executor_applies_retry_after_and_retries_http_429_for_retry_safe_method() {
let (url, handle) = serve_rate_limit_then_success();
let pool = pool_for_url(url.as_str(), std::time::Duration::from_millis(500), 1);
let result = pool
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
.await
.expect("retry-safe request must recover from one 429");
assert_eq!(result, serde_json::json!("ok"));
assert_eq!(handle.join().expect("fixture server must join"), 2);
let snapshot = pool.snapshot();
assert_eq!(snapshot.endpoints()[0].roles()[0].rate_limit_count(), 1);
assert_eq!(snapshot.endpoints()[0].roles()[0].success_count(), 1);
}
#[tokio::test(flavor = "current_thread")]
async fn executor_maps_reqwest_timeout_to_ksp_timeout_error_without_endpoint_secret_leak() {
const SECRET_CANARY: &str = "SECRET-REQWEST-URL-CANARY";
let (url, handle) = serve_timeout();
let sensitive_url = format!("{url}/rpc?api-key={SECRET_CANARY}");
let pool = pool_for_url(sensitive_url.as_str(), std::time::Duration::from_millis(20), 0);
let error = pool
.execute_standard_rpc(&crate::HttpRoleName::new("default"), health_method(), std::vec::Vec::new())
.await
.expect_err("timed out request must fail");
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
assert!(!format!("{error:?}").contains(SECRET_CANARY));
let source = std::error::Error::source(&error).expect("transport timeout should preserve a sanitized reqwest source");
assert!(!format!("{source:?}").contains(SECRET_CANARY));
handle.join().expect("fixture server must join");
}

View File

@@ -0,0 +1,336 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/http_pool.rs
// version: 5
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 non_zero(value: u32) -> std::num::NonZeroU32 {
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
}
fn limited_endpoint(
name: &str,
priority: u32,
requests_per_second: std::option::Option<u32>,
burst_capacity: std::option::Option<u32>,
max_concurrent_requests: std::option::Option<u32>,
cooldown: std::option::Option<std::time::Duration>,
) -> crate::HttpEndpointSettings {
let limits = crate::HttpRoleLimits::new(
requests_per_second.map(|value| return non_zero(value)),
burst_capacity.map(|value| return non_zero(value)),
max_concurrent_requests.map(|value| return non_zero(value)),
cooldown,
);
let role = crate::HttpEndpointRoleSettings::new(crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], priority, limits);
return crate::HttpEndpointSettings::new(
name,
true,
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],
);
}
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 = crate::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 = crate::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 = crate::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 = crate::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 = crate::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 = crate::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 = crate::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 enabled_non_matching_role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("maintenance"),
true,
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, enabled_non_matching_role],
);
let pool = crate::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 = crate::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);
}
#[tokio::test]
async fn runtime_concurrency_saturation_falls_back_to_lower_priority_tier() {
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint("primary", 1, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None),
]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must acquire primary");
assert_eq!(first.selection().endpoint_name(), "primary");
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("second request must fall back while primary is saturated");
assert_eq!(second.selection().endpoint_name(), "fallback");
}
#[tokio::test]
async fn runtime_token_bucket_exhaustion_falls_back_without_busy_waiting() {
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint("primary", 1, std::option::Option::Some(1), std::option::Option::Some(1), std::option::Option::None, std::option::Option::None),
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first request must consume primary token");
assert_eq!(first.selection().endpoint_name(), "primary");
drop(first);
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be used while primary token bucket refills");
assert_eq!(second.selection().endpoint_name(), "fallback");
}
#[tokio::test]
async fn provider_cooldown_excludes_rate_limited_role_and_uses_fallback() {
let pool = crate::HttpTransportPool::new(settings(std::vec![
limited_endpoint(
"primary",
1,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::time::Duration::from_millis(50)),
),
limited_endpoint("fallback", 20, std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let primary = pool.acquire_for_request_kind(&role, &kind).await.expect("primary must be acquired");
assert_eq!(primary.selection().endpoint_name(), "primary");
let pause = primary.record_rate_limited(std::option::Option::None);
assert_eq!(pause, std::time::Duration::from_millis(50));
drop(primary);
let fallback = pool.acquire_for_request_kind(&role, &kind).await.expect("fallback must be selected during primary cooldown");
assert_eq!(fallback.selection().endpoint_name(), "fallback");
}
#[tokio::test]
async fn admission_waits_for_released_concurrency_without_holding_a_sync_mutex_across_await() {
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(1),
std::option::Option::None,
)]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
let release_task = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
drop(first);
});
let second = pool
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(100))
.await
.expect("second permit must wake after concurrency release");
assert_eq!(second.selection().endpoint_name(), "primary");
release_task.await.expect("release task must complete");
}
#[tokio::test]
async fn admission_timeout_is_bounded_when_concurrency_never_becomes_available() {
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(1),
std::option::Option::None,
)]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let _held = pool.acquire_for_request_kind(&role, &kind).await.expect("first permit must be acquired");
let error = pool
.acquire_for_request_kind_with_timeout(&role, &kind, std::time::Duration::from_millis(20))
.await
.expect_err("second permit must time out while concurrency remains saturated");
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
}
#[tokio::test]
async fn passive_health_snapshot_moves_from_degraded_back_to_available_after_success() {
let pool = crate::HttpTransportPool::new(settings(std::vec![limited_endpoint(
"primary",
1,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
)]))
.expect("pool must build");
let role = crate::HttpRoleName::new("default");
let kind = crate::HttpRequestKind::new("get_balance");
let first = pool.acquire_for_request_kind(&role, &kind).await.expect("request permit must be acquired");
first.record_failure();
drop(first);
let degraded = pool.snapshot();
assert_eq!(degraded.endpoints()[0].availability(), crate::HttpEndpointAvailability::Degraded);
assert_eq!(degraded.endpoints()[0].roles()[0].failure_count(), 1);
let second = pool.acquire_for_request_kind(&role, &kind).await.expect("degraded endpoint remains eligible for passive recovery");
second.record_success();
drop(second);
let recovered = pool.snapshot();
assert_eq!(recovered.endpoints()[0].availability(), crate::HttpEndpointAvailability::Available);
assert_eq!(recovered.endpoints()[0].roles()[0].success_count(), 1);
}

View File

@@ -0,0 +1,186 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/http_resilience.rs
// version: 3
fn non_zero(value: u32) -> std::num::NonZeroU32 {
return std::num::NonZeroU32::new(value).expect("test limit must be non-zero");
}
fn retry_settings() -> crate::HttpRetrySettings {
return crate::HttpRetrySettings::new(4, std::time::Duration::from_millis(100), std::time::Duration::from_millis(500));
}
fn method(name: &str) -> &'static crate::HttpRpcMethodDescriptor {
return crate::find_http_rpc_method(name).expect("audited test method must exist");
}
fn role_limits(
requests_per_second: std::option::Option<u32>,
burst_capacity: std::option::Option<u32>,
max_concurrent_requests: std::option::Option<u32>,
cooldown: std::option::Option<std::time::Duration>,
) -> crate::HttpEndpointRoleSettings {
return crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(
requests_per_second.map(|value| return non_zero(value)),
burst_capacity.map(|value| return non_zero(value)),
max_concurrent_requests.map(|value| return non_zero(value)),
cooldown,
),
);
}
#[test]
fn retry_backoff_is_exponential_and_bounded() {
let settings = retry_settings();
assert_eq!(super::retry_backoff(&settings, 1), std::time::Duration::from_millis(100));
assert_eq!(super::retry_backoff(&settings, 2), std::time::Duration::from_millis(200));
assert_eq!(super::retry_backoff(&settings, 3), std::time::Duration::from_millis(400));
assert_eq!(super::retry_backoff(&settings, 4), std::time::Duration::from_millis(500));
assert_eq!(super::retry_backoff(&settings, 32), std::time::Duration::from_millis(500));
}
#[test]
fn retry_safe_timeout_is_retried_until_budget_is_exhausted() {
let settings = retry_settings();
let first = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
crate::HttpRetryCause::Timeout,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::None,
);
assert_eq!(first, crate::HttpRetryDecision::RetryAfter(std::time::Duration::from_millis(100)));
let exhausted = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
crate::HttpRetryCause::Timeout,
crate::HttpDispatchState::DispatchedAmbiguous,
settings.max_retries(),
std::option::Option::None,
);
assert_eq!(exhausted, crate::HttpRetryDecision::Stop);
}
#[test]
fn write_submission_never_retries_after_ambiguous_dispatch() {
let decision = crate::evaluate_transport_retry(
method("sendTransaction"),
&retry_settings(),
crate::HttpRetryCause::Connection,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::None,
);
assert_eq!(decision, crate::HttpRetryDecision::Stop);
}
#[test]
fn write_submission_can_retry_when_transport_proves_no_dispatch() {
let decision = crate::evaluate_transport_retry(
method("sendTransaction"),
&retry_settings(),
crate::HttpRetryCause::Connection,
crate::HttpDispatchState::NotDispatched,
0,
std::option::Option::None,
);
assert!(decision.should_retry());
}
#[test]
fn rpc_application_and_invalid_response_are_not_transport_retries() {
for cause in [crate::HttpRetryCause::RpcApplication, crate::HttpRetryCause::InvalidResponse, crate::HttpRetryCause::Request] {
let decision = crate::evaluate_transport_retry(
method("getBalance"),
&retry_settings(),
cause,
crate::HttpDispatchState::NotDispatched,
0,
std::option::Option::None,
);
assert_eq!(decision, crate::HttpRetryDecision::Stop);
}
}
#[test]
fn provider_retry_after_can_extend_backoff_but_is_defensively_bounded() {
let settings = retry_settings();
let extended = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
crate::HttpRetryCause::RateLimited,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::Some(std::time::Duration::from_secs(3)),
);
assert_eq!(extended.delay(), std::option::Option::Some(std::time::Duration::from_secs(3)));
let bounded = crate::evaluate_transport_retry(
method("getBalance"),
&settings,
crate::HttpRetryCause::RateLimited,
crate::HttpDispatchState::DispatchedAmbiguous,
0,
std::option::Option::Some(std::time::Duration::from_secs(600)),
);
assert_eq!(bounded.delay(), std::option::Option::Some(std::time::Duration::from_secs(60)));
}
#[test]
fn token_bucket_consumes_burst_then_refills_from_elapsed_time() {
let start = std::time::Instant::now();
let mut bucket = super::HttpTokenBucketState::new(2, 2, start);
assert!(bucket.try_consume_at(start).is_none());
assert!(bucket.try_consume_at(start).is_none());
assert!(bucket.try_consume_at(start).is_some());
let later = start.checked_add(std::time::Duration::from_millis(500)).expect("test instant must advance");
assert!(bucket.try_consume_at(later).is_none());
}
#[test]
fn absent_burst_capacity_defaults_to_one_second_of_rps_capacity() {
let role = role_limits(std::option::Option::Some(2), std::option::Option::None, std::option::Option::None, std::option::Option::None);
let runtime = std::sync::Arc::new(crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let now = std::time::Instant::now();
let first = runtime.try_acquire(now);
let second = runtime.try_acquire(now);
let third = runtime.try_acquire(now);
assert!(matches!(first, crate::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(second, crate::RoleAdmissionAttempt::Ready(_)));
assert!(matches!(third, crate::RoleAdmissionAttempt::BlockedUntil(_)));
}
#[test]
fn concurrency_semaphore_releases_capacity_when_permit_is_dropped() {
let role = role_limits(std::option::Option::None, std::option::Option::None, std::option::Option::Some(1), std::option::Option::None);
let runtime = std::sync::Arc::new(crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new())));
let now = std::time::Instant::now();
let first = runtime.try_acquire(now);
let held = match first {
crate::RoleAdmissionAttempt::Ready(permit) => permit,
_ => panic!("first concurrency permit must be available"),
};
assert!(matches!(runtime.try_acquire(now), crate::RoleAdmissionAttempt::ConcurrencySaturated));
drop(held);
assert!(matches!(runtime.try_acquire(now), crate::RoleAdmissionAttempt::Ready(_)));
}
#[test]
fn rate_limit_cooldown_marks_role_and_caps_provider_delay() {
let role = role_limits(
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(std::time::Duration::from_millis(10)),
);
let runtime = crate::HttpRoleRuntime::new(&role, std::sync::Arc::new(tokio::sync::Notify::new()));
let pause = runtime.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(600)));
assert_eq!(pause, std::time::Duration::from_secs(60));
assert_eq!(runtime.rate_limit_count(), 1);
assert_eq!(runtime.failure_count(), 1);
assert_eq!(runtime.availability(std::time::Instant::now()), crate::HttpEndpointAvailability::RateLimited);
}

View File

@@ -0,0 +1,207 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/http_settings.rs
// version: 3
fn non_zero(value: u32) -> std::num::NonZeroU32 {
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
}
fn valid_settings(url_text: &str) -> crate::HttpTransportSettings {
let url = crate::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
let limits = crate::HttpRoleLimits::new(
std::option::Option::Some(non_zero(10)),
std::option::Option::Some(non_zero(20)),
std::option::Option::Some(non_zero(4)),
std::option::Option::Some(std::time::Duration::from_millis(500)),
);
let role = crate::HttpEndpointRoleSettings::new(crate::HttpRoleName::new("default"), true, std::vec![crate::HttpRequestKind::wildcard()], 100, limits);
let endpoint = crate::HttpEndpointSettings::new(
"devnet_public",
true,
crate::HttpProviderName::new("solana-public"),
crate::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(5),
std::time::Duration::from_secs(15),
std::option::Option::Some(8),
std::vec![role],
);
return crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
);
}
#[test]
fn endpoint_url_accepts_http_and_https() {
assert!(crate::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
assert!(crate::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
}
#[test]
fn endpoint_url_rejects_non_http_schemes() {
let result = crate::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
let error = result.expect_err("WebSocket URL must not be accepted by HTTP settings");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn endpoint_url_debug_redacts_secret_material() {
let url = crate::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
let rendered = format!("{url:?}");
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
}
#[test]
fn valid_transport_settings_pass_validation() {
let settings = valid_settings("https://api.devnet.solana.com");
assert!(settings.validate().is_ok());
}
#[test]
fn transport_settings_debug_does_not_leak_endpoint_url() {
let settings = valid_settings("https://provider.invalid/rpc?api-key=SECRET-CANARY");
let rendered = format!("{settings:?}");
assert!(!rendered.contains("SECRET-CANARY"));
assert!(!rendered.contains("provider.invalid"));
assert!(rendered.contains("HttpEndpointUrl(<redacted>)"));
}
#[test]
fn transport_settings_require_one_enabled_endpoint() {
let url = crate::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
100,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = crate::HttpEndpointSettings::new(
"disabled",
false,
crate::HttpProviderName::new("provider"),
crate::HttpClusterName::new("devnet"),
url,
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::None,
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
);
let error = settings.validate().expect_err("all-disabled settings must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn transport_settings_reject_duplicate_endpoint_names() {
let first = valid_settings("https://one.invalid");
let second = valid_settings("https://two.invalid");
let settings = crate::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
let error = settings.validate().expect_err("duplicate endpoint names must fail");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
}
#[test]
fn transport_settings_reject_duplicate_roles() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let duplicated_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
endpoint.cluster().clone(),
endpoint.url().clone(),
endpoint.connect_timeout(),
endpoint.request_timeout(),
endpoint.max_idle_connections_per_host(),
std::vec![endpoint.roles()[0].clone(), endpoint.roles()[0].clone()],
);
let settings = crate::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
#[test]
fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard(), crate::HttpRequestKind::new("get_balance")],
100,
endpoint.roles()[0].limits().clone(),
);
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
endpoint.cluster().clone(),
endpoint.url().clone(),
endpoint.connect_timeout(),
endpoint.request_timeout(),
endpoint.max_idle_connections_per_host(),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
#[test]
fn transport_settings_reject_burst_without_rps() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
100,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
);
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
endpoint.cluster().clone(),
endpoint.url().clone(),
endpoint.connect_timeout(),
endpoint.request_timeout(),
endpoint.max_idle_connections_per_host(),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}
#[test]
fn transport_settings_reject_reversed_retry_backoff() {
let base = valid_settings("https://api.devnet.solana.com");
let settings = crate::HttpTransportSettings::new(
base.endpoints().to_vec(),
crate::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
);
assert!(settings.validate().is_err());
}
#[test]
fn transport_settings_reject_zero_request_timeout() {
let base = valid_settings("https://api.devnet.solana.com");
let endpoint = &base.endpoints()[0];
let modified_endpoint = crate::HttpEndpointSettings::new(
endpoint.name(),
true,
endpoint.provider().clone(),
endpoint.cluster().clone(),
endpoint.url().clone(),
endpoint.connect_timeout(),
std::time::Duration::ZERO,
endpoint.max_idle_connections_per_host(),
endpoint.roles().to_vec(),
);
let settings = crate::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
assert!(settings.validate().is_err());
}