v0.2.1-pre.005
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 102
|
||||
# version: 103
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1-pre.4.fix.2"
|
||||
version = "0.2.1-pre.5"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: ROADMAP.md -->
|
||||
<!-- version: 30 -->
|
||||
<!-- version: 31 -->
|
||||
|
||||
# Roadmap KSP
|
||||
|
||||
@@ -41,7 +41,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
||||
### Cadrage
|
||||
|
||||
- [X] `0.2.0` — Audit bot3, ordre fonctionnel de `0.2.x`, architecture durable, discipline de sizing et pipeline RAW/CORE/DECODE/SPECIALIZED stabilisés.
|
||||
- [/] `0.2.1` — HTTP foundation en cours : matrice 52+14, crate/settings/JSON-RPC/registry, client/pool/routing et résilience runtime (RPS/burst, concurrence, cooldown, deadline et retry borné/no-resend) acquis ; 4 canaris, Config standard et clôture restent planifiés.
|
||||
- [/] `0.2.1` — HTTP foundation en cours : matrice 52+14, crate/settings/JSON-RPC/registry, client/pool/routing, résilience runtime et exécution HTTP acquises ; les 4 canaris typés (`getBalance`, `getGenesisHash`, `getHealth`, `getVersion`) sont matérialisés, Config standard et clôture restent planifiés.
|
||||
|
||||
### Releases fonctionnelles décidées/pressenties
|
||||
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
// version: 5
|
||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
// version: 1
|
||||
|
||||
//! Integration canary for the direct dependency firewall of `ksp-onchain-transport-lib`.
|
||||
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||
|
||||
#[test]
|
||||
fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during integration tests");
|
||||
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}");
|
||||
}
|
||||
assert!(manifest.contains("ksp-core-lib"));
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
||||
assert!(manifest.contains("[dev-dependencies]"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let parent = manifest_directory.parent();
|
||||
assert!(parent.is_some(), "core crate must have a crates directory parent");
|
||||
let parent = match parent {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return manifest_directory.to_path_buf(),
|
||||
};
|
||||
let root = parent.parent();
|
||||
assert!(root.is_some(), "core crate must have a workspace root");
|
||||
return match root {
|
||||
std::option::Option::Some(value) => value.to_path_buf(),
|
||||
std::option::Option::None => parent.to_path_buf(),
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_dependency_table_does_not_activate_consumer_features() {
|
||||
let manifest_directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace_root = manifest_directory.parent().and_then(std::path::Path::parent);
|
||||
assert!(workspace_root.is_some(), "transport crate must have a workspace root");
|
||||
let workspace_root = match workspace_root {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let manifest_path = workspace_root.join("Cargo.toml");
|
||||
let manifest_path = workspace_root().join("Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path.as_path());
|
||||
assert!(manifest.is_ok(), "workspace manifest must be readable during integration tests");
|
||||
let manifest = match manifest {
|
||||
@@ -57,3 +51,18 @@ fn workspace_dependency_table_does_not_activate_consumer_features() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||
let manifest_path = workspace_root().join("crates/ksp-onchain-transport-lib/Cargo.toml");
|
||||
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during workspace integration tests");
|
||||
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}");
|
||||
}
|
||||
assert!(manifest.contains("ksp-core-lib"));
|
||||
assert!(manifest.contains("ksp-logging-lib"));
|
||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
||||
assert!(manifest.contains("[dev-dependencies]"));
|
||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"3.1.8","slot":123456789},"value":424242},"id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":"ok","id":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"jsonrpc":"2.0","result":{"solana-core":"3.1.8","feature-set":2891131721},"id":1}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/client.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -163,6 +163,26 @@ impl HttpEndpointSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HttpEndpointHttpResponse {
|
||||
status: u16,
|
||||
retry_after: std::option::Option<std::time::Duration>,
|
||||
body: std::vec::Vec<u8>,
|
||||
}
|
||||
|
||||
impl HttpEndpointHttpResponse {
|
||||
pub(crate) const fn status(&self) -> u16 {
|
||||
return self.status;
|
||||
}
|
||||
|
||||
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
|
||||
return self.retry_after;
|
||||
}
|
||||
|
||||
pub(crate) fn body(&self) -> &[u8] {
|
||||
return self.body.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.
|
||||
@@ -173,7 +193,7 @@ pub struct HttpEndpointClient {
|
||||
|
||||
struct HttpEndpointClientInner {
|
||||
settings: crate::HttpEndpointSettings,
|
||||
_client: reqwest::Client,
|
||||
client: reqwest::Client,
|
||||
role_runtimes: std::vec::Vec<std::sync::Arc<crate::resilience::HttpRoleRuntime>>,
|
||||
}
|
||||
|
||||
@@ -212,7 +232,7 @@ impl HttpEndpointClient {
|
||||
role_count = settings.roles().len(),
|
||||
"created logical HTTP endpoint client"
|
||||
);
|
||||
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, _client: client, role_runtimes }) });
|
||||
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, client, role_runtimes }) });
|
||||
}
|
||||
|
||||
/// Returns the endpoint identity used for safe diagnostics and routing.
|
||||
@@ -245,6 +265,36 @@ impl HttpEndpointClient {
|
||||
return self.inner.settings.request_timeout();
|
||||
}
|
||||
|
||||
pub(crate) async fn post_json_rpc(&self, payload: &str, timeout: std::time::Duration) -> ksp_core_lib::Result<HttpEndpointHttpResponse> {
|
||||
if timeout.is_zero() {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "HTTP JSON-RPC request budget expired before dispatch")
|
||||
.with_context("endpoint_name", self.name()),
|
||||
);
|
||||
}
|
||||
let send_result = self
|
||||
.inner
|
||||
.client
|
||||
.post(self.inner.settings.url().as_str())
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(payload.to_owned())
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await;
|
||||
let response = match send_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let retry_after = parse_retry_after(response.headers());
|
||||
let body_result = response.bytes().await;
|
||||
let body = match body_result {
|
||||
std::result::Result::Ok(body) => body.to_vec(),
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
||||
};
|
||||
return std::result::Result::Ok(HttpEndpointHttpResponse { status, retry_after, body });
|
||||
}
|
||||
|
||||
/// Returns whether one enabled role can serve the requested capability structurally.
|
||||
#[must_use]
|
||||
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
|
||||
@@ -410,6 +460,33 @@ fn fallback_role_snapshot(role: &crate::HttpEndpointRoleSettings, request_kinds:
|
||||
};
|
||||
}
|
||||
|
||||
fn map_reqwest_error(endpoint_name: &str, error: reqwest::Error) -> ksp_core_lib::Error {
|
||||
let code = if error.is_timeout() {
|
||||
crate::ERROR_CODE_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
||||
} else {
|
||||
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
||||
};
|
||||
return ksp_core_lib::Error::new(code, "HTTP JSON-RPC request failed").with_context("endpoint_name", endpoint_name).with_source(error);
|
||||
}
|
||||
|
||||
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
|
||||
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return std::option::Option::None,
|
||||
};
|
||||
let text = match value.to_str() {
|
||||
std::result::Result::Ok(text) => text.trim(),
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let seconds = match text.parse::<u64>() {
|
||||
std::result::Result::Ok(seconds) => seconds,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
232
crates/ksp-onchain-transport-lib/src/executor.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/executor.rs
|
||||
// version: 1
|
||||
|
||||
const HTTP_REQUEST_TIMEOUT: u16 = 408;
|
||||
const HTTP_TOO_MANY_REQUESTS: u16 = 429;
|
||||
const HTTP_INTERNAL_SERVER_ERROR: u16 = 500;
|
||||
const HTTP_BAD_GATEWAY: u16 = 502;
|
||||
const HTTP_SERVICE_UNAVAILABLE: u16 = 503;
|
||||
const HTTP_GATEWAY_TIMEOUT: u16 = 504;
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes one audited standard Solana HTTP JSON-RPC method through KSP routing, admission and bounded retry policy.
|
||||
///
|
||||
/// This generic transport surface intentionally returns the raw JSON result. Typed method coverage remains explicit and is provided separately by
|
||||
/// method-specific KSP adapters.
|
||||
pub async fn execute_standard_rpc(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
params: std::vec::Vec<serde_json::Value>,
|
||||
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
let support = method.ensure_runtime_supported();
|
||||
if let std::result::Result::Err(error) = support {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let request_id = self.next_request_id();
|
||||
let request_result = crate::JsonRpcRequest::new(request_id, method.method(), params);
|
||||
let request = match request_result {
|
||||
std::result::Result::Ok(request) => request,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let payload_result = request.to_json_string();
|
||||
let payload = match payload_result {
|
||||
std::result::Result::Ok(payload) => payload,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let request_kind = crate::HttpRequestKind::new(method.request_kind());
|
||||
let timeout_result = self.common_request_timeout(role, &request_kind);
|
||||
let timeout = match timeout_result {
|
||||
std::result::Result::Ok(timeout) => timeout,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
let deadline = match started.checked_add(timeout) {
|
||||
std::option::Option::Some(deadline) => deadline,
|
||||
std::option::Option::None => return execution_timeout(method, "HTTP JSON-RPC execution deadline could not be represented"),
|
||||
};
|
||||
let mut completed_retries = 0_u32;
|
||||
loop {
|
||||
let remaining = remaining_budget(deadline);
|
||||
if remaining.is_zero() {
|
||||
return execution_timeout(method, "HTTP JSON-RPC execution deadline expired before transport attempt");
|
||||
}
|
||||
let permit_result = self.acquire_for_request_kind_with_timeout(role, &request_kind, remaining).await;
|
||||
let permit = match permit_result {
|
||||
std::result::Result::Ok(permit) => permit,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let send_timeout = std::cmp::min(remaining_budget(deadline), permit.client().request_timeout());
|
||||
let response_result = permit.client().post_json_rpc(payload.as_str(), send_timeout).await;
|
||||
let response = match response_result {
|
||||
std::result::Result::Ok(response) => response,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
let cause = retry_cause_for_error(&error);
|
||||
let dispatch_state = dispatch_state_for_error(&error);
|
||||
let decision =
|
||||
crate::evaluate_transport_retry(method, self.retry_settings(), cause, dispatch_state, completed_retries, std::option::Option::None);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC retry delay exceeded the common request deadline");
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let status = response.status();
|
||||
if status == HTTP_TOO_MANY_REQUESTS {
|
||||
let provider_retry_after = response.retry_after();
|
||||
permit.record_rate_limited(provider_retry_after);
|
||||
let decision = crate::evaluate_transport_retry(
|
||||
method,
|
||||
self.retry_settings(),
|
||||
crate::HttpRetryCause::RateLimited,
|
||||
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||
completed_retries,
|
||||
provider_retry_after,
|
||||
);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC rate-limit retry exceeded the common request deadline");
|
||||
}
|
||||
return rate_limited_error(method, provider_retry_after);
|
||||
}
|
||||
if is_temporary_http_status(status) {
|
||||
permit.record_failure();
|
||||
let decision = crate::evaluate_transport_retry(
|
||||
method,
|
||||
self.retry_settings(),
|
||||
crate::HttpRetryCause::TemporaryHttp,
|
||||
crate::HttpDispatchState::DispatchedAmbiguous,
|
||||
completed_retries,
|
||||
std::option::Option::None,
|
||||
);
|
||||
drop(permit);
|
||||
if let std::option::Option::Some(delay) = decision.delay() {
|
||||
let waited = wait_retry_delay(delay, deadline).await;
|
||||
if waited {
|
||||
completed_retries = completed_retries.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
return execution_timeout(method, "HTTP JSON-RPC temporary-status retry exceeded the common request deadline");
|
||||
}
|
||||
return http_status_error(method, status);
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
permit.record_success();
|
||||
return http_status_error(method, status);
|
||||
}
|
||||
let response_text = match std::str::from_utf8(response.body()) {
|
||||
std::result::Result::Ok(text) => text,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "HTTP JSON-RPC response body is not valid UTF-8")
|
||||
.with_context("rpc_method", method.method())
|
||||
.with_source(error),
|
||||
);
|
||||
},
|
||||
};
|
||||
let parsed_result = crate::parse_json_rpc_response_text(response_text, request_id);
|
||||
let parsed = match parsed_result {
|
||||
std::result::Result::Ok(parsed) => parsed,
|
||||
std::result::Result::Err(error) => {
|
||||
permit.record_failure();
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
permit.record_success();
|
||||
ksp_logging_lib::debug!(
|
||||
target: env!("CARGO_PKG_NAME"),
|
||||
endpoint_name = permit.selection().endpoint_name(),
|
||||
role = permit.selection().role().as_str(),
|
||||
rpc_method = method.method(),
|
||||
request_id,
|
||||
completed_retries,
|
||||
http_status = status,
|
||||
"completed Solana HTTP JSON-RPC request"
|
||||
);
|
||||
return parsed.into_result();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_cause_for_error(error: &ksp_core_lib::Error) -> crate::HttpRetryCause {
|
||||
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||
return crate::HttpRetryCause::Connection;
|
||||
}
|
||||
if error.code() == crate::ERROR_CODE_TIMEOUT {
|
||||
return crate::HttpRetryCause::Timeout;
|
||||
}
|
||||
return crate::HttpRetryCause::Request;
|
||||
}
|
||||
|
||||
fn dispatch_state_for_error(error: &ksp_core_lib::Error) -> crate::HttpDispatchState {
|
||||
if error.code() == crate::ERROR_CODE_HTTP_CONNECTION_FAILED {
|
||||
return crate::HttpDispatchState::NotDispatched;
|
||||
}
|
||||
return crate::HttpDispatchState::DispatchedAmbiguous;
|
||||
}
|
||||
|
||||
const fn is_temporary_http_status(status: u16) -> bool {
|
||||
return status == HTTP_REQUEST_TIMEOUT
|
||||
|| status == HTTP_INTERNAL_SERVER_ERROR
|
||||
|| status == HTTP_BAD_GATEWAY
|
||||
|| status == HTTP_SERVICE_UNAVAILABLE
|
||||
|| status == HTTP_GATEWAY_TIMEOUT;
|
||||
}
|
||||
|
||||
fn remaining_budget(deadline: std::time::Instant) -> std::time::Duration {
|
||||
let now = std::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return std::time::Duration::ZERO;
|
||||
}
|
||||
return deadline.duration_since(now);
|
||||
}
|
||||
|
||||
async fn wait_retry_delay(delay: std::time::Duration, deadline: std::time::Instant) -> bool {
|
||||
let remaining = remaining_budget(deadline);
|
||||
if remaining.is_zero() || delay >= remaining {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
return std::time::Instant::now() < deadline;
|
||||
}
|
||||
|
||||
fn execution_timeout(method: &crate::HttpRpcMethodDescriptor, message: &str) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message).with_context("rpc_method", method.method()));
|
||||
}
|
||||
|
||||
fn rate_limited_error(
|
||||
method: &crate::HttpRpcMethodDescriptor,
|
||||
provider_retry_after: std::option::Option<std::time::Duration>,
|
||||
) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
let mut error = ksp_core_lib::Error::new(crate::ERROR_CODE_RATE_LIMITED, "Solana HTTP endpoint rate-limited the JSON-RPC request")
|
||||
.with_context("rpc_method", method.method());
|
||||
if let std::option::Option::Some(delay) = provider_retry_after {
|
||||
error = error.with_context("retry_after_seconds", delay.as_secs().to_string());
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
|
||||
fn http_status_error(method: &crate::HttpRpcMethodDescriptor, status: u16) -> ksp_core_lib::Result<serde_json::Value> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "Solana HTTP endpoint returned an unsuccessful status")
|
||||
.with_context("rpc_method", method.method())
|
||||
.with_context("http_status", status.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/executor.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -8,14 +8,16 @@
|
||||
//!
|
||||
//! 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. Logical endpoint
|
||||
//! clients, priority-aware pools, bounded admission limits and retry/no-resend policy are available, while typed Solana method adapters remain staged by
|
||||
//! subsequent `0.2.1` prereleases.
|
||||
//! clients, priority-aware pools, bounded admission limits and retry/no-resend policy are available. The first typed Solana HTTP canaries execute real
|
||||
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod json_rpc;
|
||||
mod pool;
|
||||
mod resilience;
|
||||
mod rpc_canary;
|
||||
mod rpc_method;
|
||||
mod settings;
|
||||
|
||||
@@ -81,6 +83,20 @@ pub use self::resilience::HttpRetryCause;
|
||||
pub use self::resilience::HttpRetryDecision;
|
||||
/// Evaluates the centralized bounded HTTP retry policy for one audited RPC method.
|
||||
pub use self::resilience::evaluate_transport_retry;
|
||||
/// Optional typed configuration for the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceConfig;
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
pub use self::rpc_canary::GetBalanceResult;
|
||||
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||
pub use self::rpc_canary::SolanaCommitment;
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
pub use self::rpc_canary::SolanaGenesisHash;
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
pub use self::rpc_canary::SolanaNodeHealth;
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
pub use self::rpc_canary::SolanaNodeVersion;
|
||||
/// Typed Solana RPC context used by the initial account canary.
|
||||
pub use self::rpc_canary::SolanaRpcContext;
|
||||
/// 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Safe snapshot of the logical HTTP endpoint pool.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -169,6 +169,7 @@ struct HttpTransportPoolInner {
|
||||
clients: std::vec::Vec<crate::HttpEndpointClient>,
|
||||
retry: crate::HttpRetrySettings,
|
||||
notify: std::sync::Arc<tokio::sync::Notify>,
|
||||
request_ids: std::sync::atomic::AtomicU64,
|
||||
cursors: std::sync::Mutex<std::collections::BTreeMap<(std::string::String, std::string::String, u32), usize>>,
|
||||
}
|
||||
|
||||
@@ -194,6 +195,7 @@ impl HttpTransportPool {
|
||||
clients,
|
||||
retry: settings.retry().clone(),
|
||||
notify,
|
||||
request_ids: std::sync::atomic::AtomicU64::new(1),
|
||||
cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()),
|
||||
}),
|
||||
};
|
||||
@@ -213,6 +215,14 @@ impl HttpTransportPool {
|
||||
return &self.inner.retry;
|
||||
}
|
||||
|
||||
pub(crate) fn next_request_id(&self) -> u64 {
|
||||
let id = self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if id == 0 {
|
||||
return self.inner.request_ids.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Selects an endpoint for one standard audited RPC method without reserving runtime capacity.
|
||||
///
|
||||
/// Request execution should use `acquire_for_method` so rate, cooldown and concurrency limits are enforced.
|
||||
@@ -424,7 +434,11 @@ impl HttpTransportPool {
|
||||
return std::time::Instant::now() < deadline;
|
||||
}
|
||||
|
||||
fn common_request_timeout(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result<std::time::Duration> {
|
||||
pub(crate) fn common_request_timeout(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
request_kind: &crate::HttpRequestKind,
|
||||
) -> ksp_core_lib::Result<std::time::Duration> {
|
||||
let mut timeout: std::option::Option<std::time::Duration> = std::option::Option::None;
|
||||
for client in &self.inner.clients {
|
||||
if client.matching_role(role, request_kind).is_none() {
|
||||
|
||||
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
301
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||
// version: 1
|
||||
|
||||
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaCommitment {
|
||||
/// Query the most recent processed bank.
|
||||
Processed,
|
||||
/// Query a bank confirmed by cluster vote.
|
||||
Confirmed,
|
||||
/// Query a finalized bank.
|
||||
Finalized,
|
||||
}
|
||||
|
||||
impl SolanaCommitment {
|
||||
/// Returns the Solana JSON-RPC commitment string.
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Processed => "processed",
|
||||
Self::Confirmed => "confirmed",
|
||||
Self::Finalized => "finalized",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional typed configuration for `getBalance`.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct GetBalanceConfig {
|
||||
commitment: std::option::Option<crate::SolanaCommitment>,
|
||||
min_context_slot: std::option::Option<u64>,
|
||||
}
|
||||
|
||||
impl GetBalanceConfig {
|
||||
/// Creates an explicit `getBalance` configuration.
|
||||
#[must_use]
|
||||
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
|
||||
return Self { commitment, min_context_slot };
|
||||
}
|
||||
|
||||
/// Returns the optional commitment level.
|
||||
#[must_use]
|
||||
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
|
||||
return self.commitment;
|
||||
}
|
||||
|
||||
/// Returns the optional minimum context slot.
|
||||
#[must_use]
|
||||
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
|
||||
return self.min_context_slot;
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
return self.commitment.is_none() && self.min_context_slot.is_none();
|
||||
}
|
||||
|
||||
fn to_json_value(&self) -> serde_json::Value {
|
||||
let mut object = serde_json::Map::new();
|
||||
if let std::option::Option::Some(commitment) = self.commitment {
|
||||
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
|
||||
}
|
||||
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
|
||||
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed healthy result returned by the `getHealth` canary.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum SolanaNodeHealth {
|
||||
/// The RPC node returned the stable `"ok"` health result.
|
||||
Healthy,
|
||||
}
|
||||
|
||||
/// Typed genesis hash returned by the `getGenesisHash` canary.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct SolanaGenesisHash {
|
||||
value: std::string::String,
|
||||
}
|
||||
|
||||
impl SolanaGenesisHash {
|
||||
/// Returns the base58-encoded genesis hash text.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed software-version response returned by the `getVersion` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaNodeVersion {
|
||||
solana_core: std::string::String,
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
impl SolanaNodeVersion {
|
||||
/// Returns the node software version string from the `solana-core` field.
|
||||
#[must_use]
|
||||
pub fn solana_core(&self) -> &str {
|
||||
return self.solana_core.as_str();
|
||||
}
|
||||
|
||||
/// Returns the optional runtime feature-set identifier.
|
||||
#[must_use]
|
||||
pub const fn feature_set(&self) -> std::option::Option<u32> {
|
||||
return self.feature_set;
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed Solana RPC context used by the initial account canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SolanaRpcContext {
|
||||
slot: u64,
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
impl SolanaRpcContext {
|
||||
/// Returns the context slot reported by the RPC node.
|
||||
#[must_use]
|
||||
pub const fn slot(&self) -> u64 {
|
||||
return self.slot;
|
||||
}
|
||||
|
||||
/// Returns the optional RPC API version reported by the node.
|
||||
#[must_use]
|
||||
pub fn api_version(&self) -> std::option::Option<&str> {
|
||||
return match self.api_version.as_ref() {
|
||||
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
|
||||
std::option::Option::None => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed lamport balance returned by the `getBalance` canary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GetBalanceResult {
|
||||
context: crate::SolanaRpcContext,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
impl GetBalanceResult {
|
||||
/// Returns the Solana response context.
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &crate::SolanaRpcContext {
|
||||
return &self.context;
|
||||
}
|
||||
|
||||
/// Returns the account balance in lamports.
|
||||
#[must_use]
|
||||
pub const fn value(&self) -> u64 {
|
||||
return self.value;
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::HttpTransportPool {
|
||||
/// Executes the typed `getHealth` foundation canary.
|
||||
pub async fn get_health(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeHealth> {
|
||||
let method_result = canary_descriptor("getHealth");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if value.as_str() == std::option::Option::Some("ok") {
|
||||
return std::result::Result::Ok(crate::SolanaNodeHealth::Healthy);
|
||||
}
|
||||
return invalid_canary_response("getHealth", "result must be exactly the string ok");
|
||||
}
|
||||
|
||||
/// Executes the typed `getGenesisHash` foundation canary.
|
||||
pub async fn get_genesis_hash(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaGenesisHash> {
|
||||
let method_result = canary_descriptor("getGenesisHash");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let hash = match value.as_str() {
|
||||
std::option::Option::Some(hash) => hash,
|
||||
std::option::Option::None => return invalid_canary_response("getGenesisHash", "result must be a string"),
|
||||
};
|
||||
if hash.is_empty() || hash.trim() != hash {
|
||||
return invalid_canary_response("getGenesisHash", "result must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaGenesisHash { value: hash.to_owned() });
|
||||
}
|
||||
|
||||
/// Executes the typed `getVersion` foundation canary.
|
||||
pub async fn get_version(&self, role: &crate::HttpRoleName) -> ksp_core_lib::Result<crate::SolanaNodeVersion> {
|
||||
let method_result = canary_descriptor("getVersion");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let result = self.execute_standard_rpc(role, method, std::vec::Vec::new()).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireNodeVersion>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getVersion", error),
|
||||
};
|
||||
if decoded.solana_core.is_empty() || decoded.solana_core.trim() != decoded.solana_core {
|
||||
return invalid_canary_response("getVersion", "solana-core must be a non-empty trimmed string");
|
||||
}
|
||||
return std::result::Result::Ok(crate::SolanaNodeVersion { solana_core: decoded.solana_core, feature_set: decoded.feature_set });
|
||||
}
|
||||
|
||||
/// Executes the typed `getBalance` foundation canary.
|
||||
pub async fn get_balance(
|
||||
&self,
|
||||
role: &crate::HttpRoleName,
|
||||
account: &ksp_core_lib::Pubkey,
|
||||
config: std::option::Option<&crate::GetBalanceConfig>,
|
||||
) -> ksp_core_lib::Result<crate::GetBalanceResult> {
|
||||
let method_result = canary_descriptor("getBalance");
|
||||
let method = match method_result {
|
||||
std::result::Result::Ok(method) => method,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut params = std::vec![serde_json::Value::String(account.to_string())];
|
||||
if let std::option::Option::Some(config) = config
|
||||
&& !config.is_empty()
|
||||
{
|
||||
params.push(config.to_json_value());
|
||||
}
|
||||
let result = self.execute_standard_rpc(role, method, params).await;
|
||||
let value = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let decode_result = serde_json::from_value::<WireBalanceResult>(value);
|
||||
let decoded = match decode_result {
|
||||
std::result::Result::Ok(decoded) => decoded,
|
||||
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
|
||||
};
|
||||
return std::result::Result::Ok(crate::GetBalanceResult {
|
||||
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
|
||||
value: decoded.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireNodeVersion {
|
||||
#[serde(rename = "solana-core")]
|
||||
solana_core: std::string::String,
|
||||
#[serde(rename = "feature-set", default)]
|
||||
feature_set: std::option::Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireRpcContext {
|
||||
slot: u64,
|
||||
#[serde(rename = "apiVersion", default)]
|
||||
api_version: std::option::Option<std::string::String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WireBalanceResult {
|
||||
context: WireRpcContext,
|
||||
value: u64,
|
||||
}
|
||||
|
||||
fn canary_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
|
||||
let descriptor = crate::find_http_rpc_method(method);
|
||||
return match descriptor {
|
||||
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
|
||||
std::option::Option::None => std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed canary descriptor is missing from the audited registry")
|
||||
.with_context("rpc_method", method),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fn invalid_canary_decode<T>(method: &str, error: serde_json::Error) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(
|
||||
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP canary response has an invalid shape")
|
||||
.with_context("rpc_method", method)
|
||||
.with_source(error),
|
||||
);
|
||||
}
|
||||
|
||||
fn invalid_canary_response<T>(method: &str, message: &str) -> ksp_core_lib::Result<T> {
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/rpc_canary.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -160,3 +160,14 @@ async fn public_async_admission_exposes_bounded_permit_without_endpoint_url() {
|
||||
assert!(!rendered.contains("ASYNC-SECRET-CANARY"));
|
||||
assert!(!rendered.contains("provider.invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_typed_canary_contracts_are_available_from_crate_root() {
|
||||
let config = ksp_onchain_transport_lib::GetBalanceConfig::new(
|
||||
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
|
||||
std::option::Option::Some(42),
|
||||
);
|
||||
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed));
|
||||
assert_eq!(config.min_context_slot(), std::option::Option::Some(42));
|
||||
assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
|
||||
}
|
||||
|
||||
136
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
136
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
// version: 1
|
||||
|
||||
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() {
|
||||
let (url, handle) = serve_timeout();
|
||||
let pool = pool_for_url(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);
|
||||
handle.join().expect("fixture server must join");
|
||||
}
|
||||
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
137
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||
// version: 1
|
||||
|
||||
fn pool_for_url(url: &str) -> 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::None),
|
||||
);
|
||||
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_secs(1),
|
||||
std::time::Duration::from_secs(1),
|
||||
std::option::Option::Some(1),
|
||||
std::vec![role],
|
||||
);
|
||||
let settings = crate::HttpTransportSettings::new(
|
||||
std::vec![endpoint],
|
||||
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
|
||||
);
|
||||
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
|
||||
}
|
||||
|
||||
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
|
||||
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 one request");
|
||||
let request = read_request(&mut stream);
|
||||
let response = 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");
|
||||
return request;
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
fn request_body(request: &str) -> serde_json::Value {
|
||||
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
|
||||
return serde_json::from_str(body).expect("fixture request body must be JSON");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_health_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_health.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_health(&crate::HttpRoleName::new("default")).await.expect("getHealth fixture must succeed");
|
||||
assert_eq!(result, crate::SolanaNodeHealth::Healthy);
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
let body = request_body(request.as_str());
|
||||
assert_eq!(body["method"], serde_json::json!("getHealth"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_genesis_hash_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_genesis_hash.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_genesis_hash(&crate::HttpRoleName::new("default")).await.expect("getGenesisHash fixture must succeed");
|
||||
assert_eq!(result.as_str(), "GH7ome3EiwEr7tu9JuTh2dpYWBJK3z69Xm1ZE3MEE6JC");
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getGenesisHash"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_version_executes_real_http_fixture() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_version.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let result = pool.get_version(&crate::HttpRoleName::new("default")).await.expect("getVersion fixture must succeed");
|
||||
assert_eq!(result.solana_core(), "3.1.8");
|
||||
assert_eq!(result.feature_set(), std::option::Option::Some(2_891_131_721));
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
assert_eq!(request_body(request.as_str())["method"], serde_json::json!("getVersion"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn typed_get_balance_executes_real_http_fixture_and_encodes_config() {
|
||||
let (url, handle) = serve_once(include_str!("../fixtures/http/get_balance.success.json"));
|
||||
let pool = pool_for_url(url.as_str());
|
||||
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("system address must parse");
|
||||
let config = crate::GetBalanceConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(123));
|
||||
let result = pool
|
||||
.get_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
|
||||
.await
|
||||
.expect("getBalance fixture must succeed");
|
||||
assert_eq!(result.value(), 424_242);
|
||||
assert_eq!(result.context().slot(), 123_456_789);
|
||||
assert_eq!(result.context().api_version(), std::option::Option::Some("3.1.8"));
|
||||
let request = handle.join().expect("fixture server must join");
|
||||
let body = request_body(request.as_str());
|
||||
assert_eq!(body["method"], serde_json::json!("getBalance"));
|
||||
assert_eq!(body["params"][0], serde_json::json!("11111111111111111111111111111111"));
|
||||
assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized"));
|
||||
assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(123));
|
||||
}
|
||||
282
deltas/0.2.1/pre.005.md
Normal file
282
deltas/0.2.1/pre.005.md
Normal file
@@ -0,0 +1,282 @@
|
||||
<!-- file: deltas/0.2.1/pre.005.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `v0.2.1-pre.005`
|
||||
|
||||
## Base
|
||||
|
||||
Base attendue :
|
||||
|
||||
```text
|
||||
v0.2.1-pre.004-fix.002
|
||||
```
|
||||
|
||||
Cette base a été validée localement par le user avec `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`.
|
||||
|
||||
Version Cargo cible :
|
||||
|
||||
```text
|
||||
0.2.1-pre.5
|
||||
```
|
||||
|
||||
## Objectif
|
||||
|
||||
Matérialiser la première exécution réseau HTTP complète de `ksp-onchain-transport-lib` et les quatre méthodes typées canari assignées à `0.2.1` :
|
||||
|
||||
```text
|
||||
getHealth
|
||||
getVersion
|
||||
getGenesisHash
|
||||
getBalance
|
||||
```
|
||||
|
||||
La tranche doit également corriger l'ownership des canaries de dépendances générales : les contrôles portant sur le workspace entier ne restent pas dans la crate métier Transport et sont centralisés dans la surface de tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe.
|
||||
|
||||
## Vérification de la surface Solana
|
||||
|
||||
La documentation Solana HTTP officielle a été revérifiée le 17 août 2026 pour les quatre canaris :
|
||||
|
||||
- `getHealth` : aucun paramètre, résultat stable `"ok"` lorsque le node est sain ;
|
||||
- `getGenesisHash` : aucun paramètre, résultat string base58 ;
|
||||
- `getVersion` : aucun paramètre, objet contenant `solana-core` et `feature-set` optionnel/null ;
|
||||
- `getBalance` : pubkey base58 obligatoire, configuration optionnelle `commitment` / `minContextSlot`, réponse `{ context, value }` où `value` est le solde en lamports.
|
||||
|
||||
## Exécuteur HTTP JSON-RPC
|
||||
|
||||
Nouvelle surface publique :
|
||||
|
||||
```text
|
||||
HttpTransportPool::execute_standard_rpc(...)
|
||||
```
|
||||
|
||||
L'exécuteur :
|
||||
|
||||
1. vérifie le descriptor RPC audité et son support runtime ;
|
||||
2. alloue un identifiant JSON-RPC numérique KSP ;
|
||||
3. construit et sérialise `JsonRpcRequest` sans exposer les params dans `Debug` ;
|
||||
4. dérive le request-kind depuis le registre ;
|
||||
5. fixe une deadline commune à partir du plus petit `request_timeout` des endpoints compatibles ;
|
||||
6. acquiert un `HttpRequestPermit`, donc respecte rôles/capabilities/priorité/fairness/RPS/burst/concurrence/cooldown ;
|
||||
7. exécute un POST `application/json` via le `reqwest::Client` privé de l'endpoint ;
|
||||
8. mappe connexion, timeout, HTTP status, JSON invalide, protocole JSON-RPC et RPC application error vers les codes KSP existants ;
|
||||
9. met à jour la santé passive du rôle ;
|
||||
10. applique le retry/backoff centralisé sans dépasser la deadline commune.
|
||||
|
||||
Les retries libèrent le permit précédent puis réacquièrent explicitement capacité RPS/concurrence : ils ne contournent donc pas les limites runtime du pool.
|
||||
|
||||
## HTTP status, `Retry-After` et retry
|
||||
|
||||
Le raccordement réseau de la résilience est désormais effectif :
|
||||
|
||||
- `429` -> `record_rate_limited`, cooldown de rôle et `HttpRetryCause::RateLimited` ;
|
||||
- `Retry-After` sous forme HTTP delta-seconds -> `Duration`, puis borne défensive déjà possédée par la résilience ;
|
||||
- `408`, `500`, `502`, `503`, `504` -> `HttpRetryCause::TemporaryHttp` ;
|
||||
- erreur de connexion reqwest -> `HttpRetryCause::Connection` + `NotDispatched` ;
|
||||
- timeout reqwest -> `HttpRetryCause::Timeout` + `DispatchedAmbiguous` ;
|
||||
- autres erreurs request -> non retryables automatiquement ;
|
||||
- RPC application error et réponse invalide restent hors retry transport.
|
||||
|
||||
Le parsing d'une forme HTTP-date de `Retry-After` n'est pas introduit dans cette tranche : si le header n'est pas un delta-seconds valide, le cooldown local configuré/fallback reste utilisé.
|
||||
|
||||
La règle `NeverAfterDispatch` reste centralisée dans `evaluate_transport_retry()` ; l'exécuteur générique ne la contourne pas et prépare donc correctement les futurs appels write/submission de `0.2.3`.
|
||||
|
||||
## Quatre canaris typés
|
||||
|
||||
### `getHealth`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_health(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaNodeHealth::Healthy
|
||||
```
|
||||
|
||||
Toute autre forme qu'un résultat string exact `"ok"` est classée `invalid_response`.
|
||||
|
||||
### `getGenesisHash`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_genesis_hash(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaGenesisHash
|
||||
```
|
||||
|
||||
Le wrapper exige un string non vide et trimé et n'utilise pas abusivement `Pubkey` pour représenter sémantiquement un hash de genesis.
|
||||
|
||||
### `getVersion`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_version(...)
|
||||
```
|
||||
|
||||
Résultat public :
|
||||
|
||||
```text
|
||||
SolanaNodeVersion
|
||||
solana_core: String
|
||||
feature_set: Option<u32>
|
||||
```
|
||||
|
||||
### `getBalance`
|
||||
|
||||
Nouvelle méthode :
|
||||
|
||||
```text
|
||||
HttpTransportPool::get_balance(...)
|
||||
```
|
||||
|
||||
L'adresse est reçue sous forme `ksp_core_lib::Pubkey`, conformément à l'ownership Core du type Solana bas niveau.
|
||||
|
||||
Nouveaux contrats :
|
||||
|
||||
```text
|
||||
SolanaCommitment
|
||||
GetBalanceConfig
|
||||
SolanaRpcContext
|
||||
GetBalanceResult
|
||||
```
|
||||
|
||||
`GetBalanceConfig` encode uniquement les champs présents ; un config vide n'ajoute pas un second paramètre inutile.
|
||||
|
||||
## Fixtures déterministes
|
||||
|
||||
Nouvelles fixtures :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||
```
|
||||
|
||||
Les tests utilisent un serveur TCP local déterministe pour exercer réellement le POST HTTP sans dépendre de Devnet ou d'Internet.
|
||||
|
||||
Ils vérifient notamment :
|
||||
|
||||
- les quatre réponses typées ;
|
||||
- le nom de méthode envoyé ;
|
||||
- les params `getBalance`, dont `commitment` et `minContextSlot` ;
|
||||
- un cycle `429 + Retry-After: 0 -> retry -> succès` ;
|
||||
- le mapping d'un timeout reqwest vers `ERROR_CODE_TIMEOUT`.
|
||||
|
||||
Le smoke réseau réel reste opt-in et appartient toujours à `pre.007`.
|
||||
|
||||
## Centralisation des canaries de dépendances
|
||||
|
||||
Le fichier suivant est supprimé :
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
Les deux contrôles qu'il contenait sont transférés vers :
|
||||
|
||||
```text
|
||||
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
```
|
||||
|
||||
Ils continuent de vérifier :
|
||||
|
||||
- absence d'activation `features = [...]` sous `[workspace.dependencies]` ;
|
||||
- maintien de `default-features` au root ;
|
||||
- firewall direct Transport -> pas de Config/Store/Program/`tracing` ;
|
||||
- feature-set direct attendu de `reqwest` et `tokio` dans le manifest Transport.
|
||||
|
||||
Nouvelle règle `DEP-CARGO-007` : les canaries qui inspectent une politique générale du workspace ou plusieurs manifests appartiennent à une surface de gouvernance/fondation, actuellement `ksp-core-lib`, et non à une crate métier spécialisée.
|
||||
|
||||
## ROADMAP et plan
|
||||
|
||||
`ROADMAP.md` reçoit uniquement la mise à jour synthétique normale de la release : exécution HTTP et quatre canaris deviennent acquis ; Config standard et clôture restent à venir.
|
||||
|
||||
Le plan `008` marque `pre.005` réalisé et ajoute son état détaillé. L'historique technique reste dans `deltas/`.
|
||||
|
||||
## Tests
|
||||
|
||||
Après déplacement des deux canaries workspace hors de Transport et ajout des nouveaux tests :
|
||||
|
||||
```text
|
||||
ksp-onchain-transport-lib : 78 tests Rust déclarés
|
||||
ksp-core-lib : +2 tests d'intégration workspace_dependencies
|
||||
```
|
||||
|
||||
La suite Transport reste propriétaire uniquement des tests fonctionnels/API de son domaine ; les canaries workspace générales sont exécutées via Core lors du `cargo test --workspace`.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune nouvelle dépendance externe et aucune nouvelle feature externe ne sont introduites.
|
||||
|
||||
La politique de `pre.004-fix.001/.002` reste inchangée : versions/default-features au root, features d'usage dans chaque crate consommatrice.
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_balance.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_genesis_hash.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_health.success.json
|
||||
crates/ksp-onchain-transport-lib/fixtures/http/get_version.success.json
|
||||
crates/ksp-onchain-transport-lib/src/executor.rs
|
||||
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/executor.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
|
||||
deltas/0.2.1/pre.005.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
ROADMAP.md
|
||||
crates/ksp-onchain-transport-lib/src/client.rs
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/src/pool.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||
docs/rules/RULES_DEPENDENCIES.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés.
|
||||
|
||||
Après application :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test -p ksp-core-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Aucune dépendance/feature n'ayant changé, un `cargo tree` complet n'est pas requis pour cette tranche. Il peut néanmoins être rejoué au checkpoint final `pre.007` comme prévu.
|
||||
|
||||
## Suite
|
||||
|
||||
Tranche suivante prévue :
|
||||
|
||||
```text
|
||||
0.2.1-pre.006
|
||||
```
|
||||
|
||||
Périmètre : document/schema/exemple Config standard `std.transport`, enregistrement dans Config, adapter Config -> Transport et tests de sensibilité/provenance/env sans créer de dépendance inverse Transport -> Config.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md -->
|
||||
<!-- version: 5 -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# `0.2.1-pre.001` — plan `ksp-onchain-transport-lib` HTTP Solana foundation
|
||||
|
||||
@@ -868,12 +868,12 @@ La release peut devenir stable seulement si :
|
||||
## 22. Prévision souple des prereleases de la `0.2.1` réduite
|
||||
|
||||
| Tranche | Objectif |
|
||||
|-----------|----------------------------------------------------------------------------------------------------------------------|
|
||||
|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `pre.001` | audit KSP + bot3 + docs officielles, matrice 52+14, architecture, split et sizing |
|
||||
| `pre.002` | **réalisé** : crate/workspace, codes erreur, settings/validation, JSON-RPC, descriptors/status, base Logging |
|
||||
| `pre.003` | **réalisé** : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs |
|
||||
| `pre.004` | **réalisé** : RPS/burst/concurrence/cooldown + deadline commune + retry/backoff + classification retry/no-resend |
|
||||
| `pre.005` | méthodes canari `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes |
|
||||
| `pre.005` | **réalisé** : exécution HTTP JSON-RPC + `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes + centralisation des canaries workspace dans Core |
|
||||
| `pre.006` | `std.transport` schema/document/example + registry Config + adapter Config -> Transport + sensibilité/env tests |
|
||||
| `pre.007` | completeness/canaries, smoke opt-in, `cargo tree`, README/USAGE, docs finales, prompt `0.2.2`, préparation `rel.001` |
|
||||
|
||||
@@ -927,13 +927,28 @@ Restent volontairement à `pre.004` : token bucket RPS/burst, semaphore de concu
|
||||
- snapshots sûrs enrichis avec availability de rôle, limites, requêtes en vol, cooldown restant et compteurs success/failure/rate-limit, sans URL ni secret ;
|
||||
- transitions passives `Available -> Degraded/RateLimited` sur observations runtime et retour `Degraded -> Available` après succès ;
|
||||
- policy centralisée `evaluate_transport_retry()` : backoff exponentiel borné, budget `max_retries`, causes transport explicitement retryables, RPC application errors hors retry, et respect strict de `TransportRetryClass::NeverAfterDispatch` pour empêcher un resend automatique après dispatch ambigu ;
|
||||
- `tokio` est consommé directement par Transport uniquement via la dépendance workspace avec feature locale `sync`; `time`/runtime restent fournis par le feature-set workspace existant ;
|
||||
- après `pre.004-fix.001/.002`, les features d’usage sont activées localement par crate : Transport porte `reqwest/rustls`, `serde/derive`, `tokio/macros+sync+time` et `tokio/rt` seulement en dev ; le root conserve version/default-features sans activation de features d’usage ;
|
||||
- la suite Transport compte désormais **72 tests déclarés**, avec preuves supplémentaires sur limiter, concurrence, cooldown, deadline, fallback runtime, santé passive, policy de retry et consommation publique de l'admission async.
|
||||
|
||||
La tranche ne parse pas encore elle-même le header HTTP `Retry-After` et n'exécute pas de POST JSON-RPC : ces signaux seront raccordés à l'exécuteur HTTP avec les méthodes canari de `pre.005`.
|
||||
|
||||
Restent à `pre.005` : exécution HTTP JSON-RPC réelle et wrappers typés `getHealth`, `getVersion`, `getGenesisHash`, `getBalance`, avec fixtures déterministes et raccordement des erreurs HTTP/429/timeout à la résilience maintenant disponible.
|
||||
|
||||
### 22.4 État après `0.2.1-pre.005`
|
||||
|
||||
`pre.005` matérialise la première exécution réseau HTTP complète et les quatre canaris typés de la release :
|
||||
|
||||
- `workspace.package.version = 0.2.1-pre.5` ;
|
||||
- exécuteur générique `execute_standard_rpc` : allocation d'id JSON-RPC KSP, admission via pool, POST `application/json`, deadline commune, mapping connexion/timeout/status HTTP, parsing JSON-RPC et mise à jour de santé passive ;
|
||||
- HTTP `429` raccordé au cooldown de rôle et à la policy de retry avec parsing défensif de `Retry-After` sous forme delta-seconds ; statuts temporaires `408/500/502/503/504` raccordés au retry borné ;
|
||||
- les retries réacquièrent capacité RPS/concurrence et restent bornés par la même deadline ; la classification `NeverAfterDispatch` reste centralisée pour empêcher les futurs write/submission de se resoumettre après ambiguïté ;
|
||||
- wrappers typés `getHealth`, `getGenesisHash`, `getVersion`, `getBalance`, avec `ksp_core_lib::Pubkey`, commitment/minContextSlot optionnels, DTO de contexte/balance/version et validation de forme ;
|
||||
- fixtures JSON déterministes et serveur HTTP local de tests : les canaris prouvent le POST réel et les formes request/response ; tests supplémentaires sur récupération après 429 et timeout reqwest ;
|
||||
- les deux canaries de dépendances générales quittent `ksp-onchain-transport-lib/tests/dependency_boundary.rs` et sont centralisées sous `ksp-core-lib/tests/workspace_dependencies.rs`, conformément à `DEP-CARGO-007` ;
|
||||
- Transport compte désormais **78 tests déclarés** ; Core porte 2 canaries workspace supplémentaires dans sa surface d'intégration.
|
||||
|
||||
Restent à `pre.006` : document/schema/exemple `std.transport`, enregistrement Config, adapter Config -> Transport et tests de sensibilité/provenance/env.
|
||||
|
||||
## 23. Séquence `0.2.x` recalibrée
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/RULES_DEPENDENCIES.md -->
|
||||
<!-- version: 12 -->
|
||||
<!-- version: 13 -->
|
||||
|
||||
# Règles des dépendances KSP
|
||||
|
||||
@@ -31,6 +31,7 @@ Elles complètent les règles Rust générales et le graphe de `docs/architectur
|
||||
- **DEP-CARGO-004** — Lorsqu'une génération majeure/mineure compatible est retenue, KSP exprime explicitement l'intention sous forme caret `^M.m` (par exemple `^4.3`) plutôt qu'avec une écriture patch telle que `4.3.0`. Même si Cargo interprète aussi par défaut cette dernière comme une contrainte compatible caret, KSP normalise la syntaxe pour rendre l'intention manifeste. Un pin exact `=M.m.p` ou un bornage différent requiert une justification explicite.
|
||||
- **DEP-CARGO-005** — Le `Cargo.lock` résout la version patch concrète à l'intérieur de la contrainte du workspace ; cette résolution ne remplace pas la politique de version déclarée dans le manifeste racine.
|
||||
- **DEP-CARGO-006** — Lorsqu'une feature externe n'est nécessaire qu'aux tests/benchmarks/examples d'une crate, son activation appartient à la section de dépendances de développement correspondante plutôt qu'aux dépendances de production. L'unification des features effectuée par Cargo lors d'un build ne transfère pas cet ownership vers le `Cargo.toml` racine.
|
||||
- **DEP-CARGO-007** — Les canaries qui inspectent une politique générale du workspace ou plusieurs manifests membres appartiennent à une surface de gouvernance/fondation (actuellement les tests de `ksp-core-lib` tant qu'aucun outil d'audit dédié n'existe). Une crate métier spécialisée conserve uniquement les tests de frontière propres à son domaine et ne devient pas propriétaire d'une règle globale du workspace.
|
||||
|
||||
## Codecs wire et cohérence des versions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user