From babe7d9f2b41be3b3282497603d011a6eef8a760 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 17 Aug 2026 18:55:14 +0200 Subject: [PATCH] v0.2.1-pre.003 --- Cargo.toml | 6 +- ROADMAP.md | 13 +- .../ksp-onchain-transport-lib/src/client.rs | 274 ++++++++++++++++++ crates/ksp-onchain-transport-lib/src/lib.rs | 23 +- crates/ksp-onchain-transport-lib/src/pool.rs | 215 ++++++++++++++ .../ksp-onchain-transport-lib/src/settings.rs | 6 +- .../tests/public_api.rs | 42 ++- .../unit_tests/client.rs | 49 ++++ .../unit_tests/pool.rs | 167 +++++++++++ deltas/0.2.1/pre.003.md | 196 +++++++++++++ docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md | 38 ++- 11 files changed, 1007 insertions(+), 22 deletions(-) create mode 100644 crates/ksp-onchain-transport-lib/src/client.rs create mode 100644 crates/ksp-onchain-transport-lib/src/pool.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/client.rs create mode 100644 crates/ksp-onchain-transport-lib/unit_tests/pool.rs create mode 100644 deltas/0.2.1/pre.003.md diff --git a/Cargo.toml b/Cargo.toml index 342c786..5833482 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 98 +# version: 99 [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.2.fix.1" +version = "0.2.1-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -18,7 +18,7 @@ fs2 = { version = "^0.4" } serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0" } jsonschema = { version = "^0.49", default-features = false } -reqwest = { version = "^0.13", default-features = false } +reqwest = { version = "^0.13", default-features = false, features = ["rustls"] } solana-pubkey = { version = "^4.3", default-features = false } tracing = { version = "^0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] } diff --git a/ROADMAP.md b/ROADMAP.md index 5cb90fe..666c703 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ - + # Roadmap KSP @@ -40,15 +40,8 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U ### Cadrage -- [X] `0.2.0` — Audit bot3, ordre de `0.2.x`, architecture durable et prompt `0.2.1` stabilisés par `0.2.0-rel.001`. -- [X] `0.2.0-pre.001` — Méthode d'audit, cartographie initiale et matrice provisoire. -- [X] `0.2.0-pre.002` — Fixer l'ordre fonctionnel, la discipline de sizing, le pipeline RAW/CORE/DECODE/SPECIALIZED et préparer le prompt `0.2.1`. -- [X] `0.2.0-pre.003` — Audit de cohérence final : règles résiduelles supersédées corrigées, fiches `0.2.1+` complétées, TODO bot3 utiles préservés et prompt `0.2.1` finalisé. -- [X] `0.2.0-rel.001` — Publication stable du cadrage `0.2.x`; prochaine release : `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation`. -- [X] `0.2.1-pre.001` — Audit HTTP officiel/bot3, matrice exhaustive, architecture et gate de sizing : scope monolithique refusé, premier split de la couverture HTTP sur `0.2.1`–`0.2.6`, `0.2.1` réduite déclarée raisonnablement clôturable. -- [X] `0.2.1-pre.001-fix.001` — Recalibrer le split HTTP sur `0.2.1`–`0.2.4` : `0.2.2` Accounts + Tokens + Cluster, `0.2.3` Transactions, `0.2.4` Blocks + Economics + compliance finale ; autoriser l'enchaînement de plusieurs releases complètement clôturées dans une même session lorsque le sizing le permet. -- [X] `0.2.1-pre.002` — Créer la crate `ksp-onchain-transport-lib`, fixer ses settings runtime publics/validation, les envelopes JSON-RPC HTTP, les codes d'erreur, le registre central 52 current + 14 historiques et la base de warnings KSP de statut. -- [X] `0.2.1-pre.002-fix.001` — Nettoyer les warnings Clippy/missing-docs révélés par la validation locale de `pre.002`, sans changer son périmètre fonctionnel ni ses contrats publics. +- [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 : audit/sizing et matrice 52+14 stabilisés ; crate/settings/JSON-RPC/registry acquis ; client endpoint/pool logique et routing priority/fairness acquis ; prochaine étape : limites, concurrence, cooldown, timeout/retry effectif ; 4 canaris, Config standard et clôture restent planifiés. ### Releases fonctionnelles décidées/pressenties diff --git a/crates/ksp-onchain-transport-lib/src/client.rs b/crates/ksp-onchain-transport-lib/src/client.rs new file mode 100644 index 0000000..c46f805 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/client.rs @@ -0,0 +1,274 @@ +// file: crates/ksp-onchain-transport-lib/src/client.rs +// version: 1 + +/// Passive runtime availability reported for one logical HTTP endpoint. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum HttpEndpointAvailability { + /// The endpoint is administratively disabled and cannot be selected. + Disabled, + /// The endpoint is enabled and currently eligible for selection. + Available, + /// The endpoint is enabled but temporarily degraded by runtime observations. + Degraded, + /// The endpoint is enabled but temporarily excluded after provider rate limiting. + RateLimited, +} + +/// Safe routing snapshot for one configured endpoint role. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpEndpointRoleSnapshot { + role: std::string::String, + enabled: bool, + request_kinds: std::vec::Vec, + priority: u32, +} + +impl HttpEndpointRoleSnapshot { + /// Returns the logical role name. + #[must_use] + pub fn role(&self) -> &str { + return self.role.as_str(); + } + + /// Returns whether the role is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Returns request-kind descriptors accepted by the role. + #[must_use] + pub fn request_kinds(&self) -> &[std::string::String] { + return self.request_kinds.as_slice(); + } + + /// Returns the routing priority where lower values are preferred. + #[must_use] + pub const fn priority(&self) -> u32 { + return self.priority; + } +} + +/// Safe metadata snapshot for one logical HTTP endpoint. +/// +/// Endpoint URLs are intentionally absent because they can contain provider credentials. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpEndpointSnapshot { + name: std::string::String, + provider: std::string::String, + cluster: std::string::String, + enabled: bool, + availability: crate::HttpEndpointAvailability, + roles: std::vec::Vec, +} + +impl HttpEndpointSnapshot { + /// Returns the configured endpoint identity. + #[must_use] + pub fn name(&self) -> &str { + return self.name.as_str(); + } + + /// Returns the provider descriptor. + #[must_use] + pub fn provider(&self) -> &str { + return self.provider.as_str(); + } + + /// Returns the cluster descriptor. + #[must_use] + pub fn cluster(&self) -> &str { + return self.cluster.as_str(); + } + + /// Returns whether the endpoint is administratively enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + return self.enabled; + } + + /// Returns the passive runtime availability. + #[must_use] + pub const fn availability(&self) -> crate::HttpEndpointAvailability { + return self.availability; + } + + /// Returns safe role snapshots in declaration order. + #[must_use] + pub fn roles(&self) -> &[crate::HttpEndpointRoleSnapshot] { + return self.roles.as_slice(); + } +} + +/// Shareable logical HTTP endpoint client owned by KSP Transport. +/// +/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata. +#[derive(Clone)] +pub struct HttpEndpointClient { + inner: std::sync::Arc, +} + +struct HttpEndpointClientInner { + settings: crate::HttpEndpointSettings, + _client: reqwest::Client, + availability: std::sync::atomic::AtomicU8, +} + +impl HttpEndpointClient { + /// Builds one logical endpoint client from KSP-owned runtime settings. + pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result { + let validation = crate::settings::validate_endpoint_settings(&settings); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + let client_result = build_reqwest_client(&settings); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CONNECTION_FAILED, "HTTP endpoint client could not be initialized") + .with_context("endpoint_name", settings.name()) + .with_source(error), + ); + }, + }; + let availability = if settings.enabled() { AVAILABILITY_AVAILABLE } else { AVAILABILITY_DISABLED }; + ksp_logging_lib::debug!( + target: env!("CARGO_PKG_NAME"), + endpoint_name = settings.name(), + provider = settings.provider().as_str(), + cluster = settings.cluster().as_str(), + enabled = settings.enabled(), + "created logical HTTP endpoint client" + ); + return std::result::Result::Ok(Self { + inner: std::sync::Arc::new(HttpEndpointClientInner { settings, _client: client, availability: std::sync::atomic::AtomicU8::new(availability) }), + }); + } + + /// Returns the endpoint identity used for safe diagnostics and routing. + #[must_use] + pub fn name(&self) -> &str { + return self.inner.settings.name(); + } + + /// Returns the provider descriptor. + #[must_use] + pub fn provider(&self) -> &crate::HttpProviderName { + return self.inner.settings.provider(); + } + + /// Returns the cluster descriptor. + #[must_use] + pub fn cluster(&self) -> &crate::HttpClusterName { + return self.inner.settings.cluster(); + } + + /// Returns whether the endpoint is administratively enabled. + #[must_use] + pub fn enabled(&self) -> bool { + return self.inner.settings.enabled(); + } + + /// Returns whether one enabled role can serve the requested capability. + #[must_use] + pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool { + return self.matching_role(role, request_kind).is_some(); + } + + /// Returns a safe endpoint snapshot with no URL or provider credential material. + #[must_use] + pub fn snapshot(&self) -> crate::HttpEndpointSnapshot { + let mut roles = std::vec::Vec::with_capacity(self.inner.settings.roles().len()); + for role in self.inner.settings.roles() { + let mut request_kinds = std::vec::Vec::with_capacity(role.request_kinds().len()); + for request_kind in role.request_kinds() { + request_kinds.push(request_kind.as_str().to_owned()); + } + roles.push(crate::HttpEndpointRoleSnapshot { + role: role.role().as_str().to_owned(), + enabled: role.enabled(), + request_kinds, + priority: role.priority(), + }); + } + return crate::HttpEndpointSnapshot { + name: self.name().to_owned(), + provider: self.provider().as_str().to_owned(), + cluster: self.cluster().as_str().to_owned(), + enabled: self.enabled(), + availability: self.availability(), + roles, + }; + } + + pub(crate) fn matching_role<'a>( + &'a self, + role: &crate::HttpRoleName, + request_kind: &crate::HttpRequestKind, + ) -> std::option::Option<&'a crate::HttpEndpointRoleSettings> { + if !self.is_selectable() { + return std::option::Option::None; + } + for candidate_role in self.inner.settings.roles() { + if !candidate_role.enabled() || candidate_role.role() != role { + continue; + } + for capability in candidate_role.request_kinds() { + if capability.is_wildcard() || capability == request_kind { + return std::option::Option::Some(candidate_role); + } + } + } + return std::option::Option::None; + } + + fn is_selectable(&self) -> bool { + return self.enabled() && self.availability() == crate::HttpEndpointAvailability::Available; + } + + fn availability(&self) -> crate::HttpEndpointAvailability { + return availability_from_code(self.inner.availability.load(std::sync::atomic::Ordering::Relaxed)); + } +} + +impl std::fmt::Debug for HttpEndpointClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.debug_struct("HttpEndpointClient").field("snapshot", &self.snapshot()).finish(); + } +} + +fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(settings.connect_timeout()) + .timeout(settings.request_timeout()) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))); + if let std::option::Option::Some(max_idle) = settings.max_idle_connections_per_host() { + builder = builder.pool_max_idle_per_host(max_idle); + } + return builder.build(); +} + +const AVAILABILITY_DISABLED: u8 = 0; +const AVAILABILITY_AVAILABLE: u8 = 1; +const AVAILABILITY_DEGRADED: u8 = 2; +const AVAILABILITY_RATE_LIMITED: u8 = 3; + +fn availability_from_code(code: u8) -> crate::HttpEndpointAvailability { + if code == AVAILABILITY_DISABLED { + return crate::HttpEndpointAvailability::Disabled; + } + if code == AVAILABILITY_DEGRADED { + return crate::HttpEndpointAvailability::Degraded; + } + if code == AVAILABILITY_RATE_LIMITED { + return crate::HttpEndpointAvailability::RateLimited; + } + return crate::HttpEndpointAvailability::Available; +} + +#[cfg(test)] +#[path = "../unit_tests/client.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index e6ac517..01a8de3 100644 --- a/crates/ksp-onchain-transport-lib/src/lib.rs +++ b/crates/ksp-onchain-transport-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/lib.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -7,14 +7,25 @@ //! KSP-owned Solana on-chain transport foundation. //! //! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains -//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter, while network -//! clients, pools, resilience and typed Solana method adapters are introduced by subsequent `0.2.1` prereleases. +//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter. Logical endpoint +//! clients and priority-aware pools are now available, while resilience limits and typed Solana method adapters remain staged by subsequent `0.2.1` +//! prereleases. +mod client; mod error; mod json_rpc; +mod pool; mod rpc_method; mod settings; +/// Passive runtime availability reported for one logical HTTP endpoint. +pub use self::client::HttpEndpointAvailability; +/// Shareable logical HTTP endpoint client owned by KSP Transport. +pub use self::client::HttpEndpointClient; +/// Safe routing snapshot for one configured endpoint role. +pub use self::client::HttpEndpointRoleSnapshot; +/// Safe metadata snapshot for one logical HTTP endpoint. +pub use self::client::HttpEndpointSnapshot; /// Error code used when no logical endpoint can satisfy a request. pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED; /// Error code used when an HTTP connection cannot be established. @@ -53,6 +64,12 @@ pub use self::json_rpc::JsonRpcSuccessResponse; pub use self::json_rpc::parse_json_rpc_response_text; /// Validates a decoded JSON value as one JSON-RPC HTTP response. pub use self::json_rpc::parse_json_rpc_response_value; +/// Result of one logical endpoint selection. +pub use self::pool::HttpEndpointSelection; +/// Shareable logical HTTP endpoint pool with priority routing and round-robin fairness. +pub use self::pool::HttpTransportPool; +/// Safe snapshot of the logical HTTP endpoint pool. +pub use self::pool::HttpTransportPoolSnapshot; /// Functional category used by the audited Solana HTTP JSON-RPC registry. pub use self::rpc_method::HttpRpcCategory; /// Release that owns typed KSP coverage for one audited HTTP RPC method. diff --git a/crates/ksp-onchain-transport-lib/src/pool.rs b/crates/ksp-onchain-transport-lib/src/pool.rs new file mode 100644 index 0000000..782d504 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/pool.rs @@ -0,0 +1,215 @@ +// file: crates/ksp-onchain-transport-lib/src/pool.rs +// version: 1 + +/// Safe snapshot of the logical HTTP endpoint pool. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HttpTransportPoolSnapshot { + endpoints: std::vec::Vec, +} + +impl HttpTransportPoolSnapshot { + /// Returns safe endpoint snapshots in configured declaration order. + #[must_use] + pub fn endpoints(&self) -> &[crate::HttpEndpointSnapshot] { + return self.endpoints.as_slice(); + } + + /// Returns the total number of configured logical endpoints. + #[must_use] + pub fn endpoint_count(&self) -> usize { + return self.endpoints.len(); + } + + /// Returns the number of endpoints currently eligible for routing. + #[must_use] + pub fn available_endpoint_count(&self) -> usize { + return self.endpoints.iter().filter(|endpoint| endpoint.availability() == crate::HttpEndpointAvailability::Available).count(); + } +} + +/// Result of one logical endpoint selection. +#[derive(Clone, Debug)] +pub struct HttpEndpointSelection { + client: crate::HttpEndpointClient, + role: crate::HttpRoleName, + request_kind: crate::HttpRequestKind, + priority: u32, +} + +impl HttpEndpointSelection { + /// Returns the selected endpoint client. + #[must_use] + pub const fn client(&self) -> &crate::HttpEndpointClient { + return &self.client; + } + + /// Returns the selected endpoint identity. + #[must_use] + pub fn endpoint_name(&self) -> &str { + return self.client.name(); + } + + /// Returns the matched logical role. + #[must_use] + pub const fn role(&self) -> &crate::HttpRoleName { + return &self.role; + } + + /// Returns the matched request-kind capability. + #[must_use] + pub const fn request_kind(&self) -> &crate::HttpRequestKind { + return &self.request_kind; + } + + /// Returns the selected role priority where lower values are preferred. + #[must_use] + pub const fn priority(&self) -> u32 { + return self.priority; + } +} + +/// Shareable logical HTTP endpoint pool with priority routing and per-capability round-robin fairness. +#[derive(Clone)] +pub struct HttpTransportPool { + inner: std::sync::Arc, +} + +struct HttpTransportPoolInner { + clients: std::vec::Vec, + cursors: std::sync::Mutex>, +} + +impl HttpTransportPool { + /// Builds a logical endpoint pool after validating all Transport-owned runtime settings. + pub fn new(settings: crate::HttpTransportSettings) -> ksp_core_lib::Result { + let validation = settings.validate(); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + let mut clients = std::vec::Vec::with_capacity(settings.endpoints().len()); + for endpoint in settings.endpoints() { + let client_result = crate::HttpEndpointClient::new(endpoint.clone()); + let client = match client_result { + std::result::Result::Ok(client) => client, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + clients.push(client); + } + let pool = Self { + inner: std::sync::Arc::new(HttpTransportPoolInner { clients, cursors: std::sync::Mutex::new(std::collections::BTreeMap::new()) }), + }; + ksp_logging_lib::debug!( + target: env!("CARGO_PKG_NAME"), + endpoint_count = pool.inner.clients.len(), + available_endpoint_count = pool.snapshot().available_endpoint_count(), + "created logical HTTP endpoint pool" + ); + return std::result::Result::Ok(pool); + } + + /// Selects an endpoint for one standard audited RPC method. + pub fn select_for_method(&self, role: &crate::HttpRoleName, method: &crate::HttpRpcMethodDescriptor) -> ksp_core_lib::Result { + let support = method.ensure_runtime_supported(); + if let std::result::Result::Err(error) = support { + return std::result::Result::Err(error); + } + return self.select_for_request_kind(role, &crate::HttpRequestKind::new(method.request_kind())); + } + + /// Selects an endpoint for an open request-kind descriptor, including provider extensions. + pub fn select_for_request_kind( + &self, + role: &crate::HttpRoleName, + request_kind: &crate::HttpRequestKind, + ) -> ksp_core_lib::Result { + let mut candidates = std::vec::Vec::new(); + for (client_index, client) in self.inner.clients.iter().enumerate() { + let matching_role = client.matching_role(role, request_kind); + if let std::option::Option::Some(matching_role) = matching_role { + candidates.push(PoolCandidate { client_index, priority: matching_role.priority() }); + } + } + if candidates.is_empty() { + return selection_failed(role, request_kind); + } + candidates.sort_by_key(|candidate| candidate.priority); + let first_candidate = candidates.first(); + let best_priority = match first_candidate { + std::option::Option::Some(candidate) => candidate.priority, + std::option::Option::None => return selection_failed(role, request_kind), + }; + let best_tier: std::vec::Vec = candidates.into_iter().take_while(|candidate| candidate.priority == best_priority).collect(); + let selected_position = self.next_position(role, request_kind, best_priority, best_tier.len()); + let selected = match best_tier.get(selected_position) { + std::option::Option::Some(selected) => selected, + std::option::Option::None => return selection_failed(role, request_kind), + }; + let selected_client = self.inner.clients.get(selected.client_index); + let client = match selected_client { + std::option::Option::Some(client) => client.clone(), + std::option::Option::None => return selection_failed(role, request_kind), + }; + ksp_logging_lib::debug!( + target: env!("CARGO_PKG_NAME"), + endpoint_name = client.name(), + role = role.as_str(), + request_kind = request_kind.as_str(), + priority = best_priority, + tier_size = best_tier.len(), + "selected logical HTTP endpoint" + ); + return std::result::Result::Ok(crate::HttpEndpointSelection { + client, + role: role.clone(), + request_kind: request_kind.clone(), + priority: best_priority, + }); + } + + /// Returns a safe pool snapshot without endpoint URLs. + #[must_use] + pub fn snapshot(&self) -> crate::HttpTransportPoolSnapshot { + let endpoints = self.inner.clients.iter().map(crate::HttpEndpointClient::snapshot).collect(); + return crate::HttpTransportPoolSnapshot { endpoints }; + } + + fn next_position(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind, priority: u32, tier_size: usize) -> usize { + let key = (role.as_str().to_owned(), request_kind.as_str().to_owned(), priority); + let lock_result = self.inner.cursors.lock(); + let mut cursors = match lock_result { + std::result::Result::Ok(cursors) => cursors, + std::result::Result::Err(poisoned) => poisoned.into_inner(), + }; + let cursor = cursors.entry(key).or_insert(0); + if tier_size == 0 { + return 0; + } + let selected = *cursor % tier_size; + *cursor = (*cursor).wrapping_add(1); + return selected; + } +} + +impl std::fmt::Debug for HttpTransportPool { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.debug_struct("HttpTransportPool").field("snapshot", &self.snapshot()).finish(); + } +} + +#[derive(Clone, Copy)] +struct PoolCandidate { + client_index: usize, + priority: u32, +} + +fn selection_failed(role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> ksp_core_lib::Result { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED, "no HTTP endpoint can satisfy the requested role and request kind") + .with_context("role", role.as_str()) + .with_context("request_kind", request_kind.as_str()), + ); +} + +#[cfg(test)] +#[path = "../unit_tests/pool.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/settings.rs b/crates/ksp-onchain-transport-lib/src/settings.rs index 3e746fd..245c85c 100644 --- a/crates/ksp-onchain-transport-lib/src/settings.rs +++ b/crates/ksp-onchain-transport-lib/src/settings.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/src/settings.rs -// version: 2 +// version: 3 /// Runtime HTTP endpoint URL owned by Transport. /// @@ -443,6 +443,10 @@ impl HttpTransportSettings { } } +pub(crate) fn validate_endpoint_settings(endpoint: &crate::HttpEndpointSettings) -> ksp_core_lib::Result<()> { + return validate_endpoint(endpoint, 0); +} + fn validate_retry(retry: &crate::HttpRetrySettings) -> ksp_core_lib::Result<()> { if retry.initial_backoff().is_zero() { return invalid_settings("initial retry backoff must be greater than zero", "retry.initial_backoff"); diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 337db31..f8ab059 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/public_api.rs -// version: 2 +// version: 3 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -61,3 +61,43 @@ fn public_error_codes_share_the_core_error_domain() { assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS.domain(), "onchain_transport"); assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_RPC_APPLICATION_ERROR.domain(), "onchain_transport"); } + +#[test] +fn public_pool_contract_selects_a_standard_method_without_exposing_url() { + let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://provider.invalid/rpc?token=SECRET-CANARY") + .expect("public URL parser must accept HTTPS endpoint"); + let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new( + ksp_onchain_transport_lib::HttpRoleName::new("default"), + true, + std::vec![ksp_onchain_transport_lib::HttpRequestKind::new("get_balance")], + 10, + ksp_onchain_transport_lib::HttpRoleLimits::new( + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ), + ); + let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new( + "primary", + true, + ksp_onchain_transport_lib::HttpProviderName::new("provider"), + ksp_onchain_transport_lib::HttpClusterName::new("devnet"), + url, + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(10), + std::option::Option::Some(8), + std::vec![role], + ); + let settings = ksp_onchain_transport_lib::HttpTransportSettings::new( + std::vec![endpoint], + ksp_onchain_transport_lib::HttpRetrySettings::new(1, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)), + ); + let pool = ksp_onchain_transport_lib::HttpTransportPool::new(settings).expect("public pool constructor must succeed"); + let method = ksp_onchain_transport_lib::find_http_rpc_method("getBalance").expect("audited method must exist"); + let selected = pool.select_for_method(&ksp_onchain_transport_lib::HttpRoleName::new("default"), method).expect("public pool must route audited method"); + assert_eq!(selected.endpoint_name(), "primary"); + let rendered = format!("{pool:?}"); + assert!(!rendered.contains("SECRET-CANARY")); + assert!(!rendered.contains("provider.invalid")); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/client.rs b/crates/ksp-onchain-transport-lib/unit_tests/client.rs new file mode 100644 index 0000000..fc525cf --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/client.rs @@ -0,0 +1,49 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/client.rs +// version: 1 + +fn endpoint(enabled: bool, url_text: &str) -> crate::HttpEndpointSettings { + let url = crate::HttpEndpointUrl::parse(url_text).expect("test endpoint URL must parse"); + let role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + true, + std::vec![crate::HttpRequestKind::wildcard()], + 10, + crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), + ); + return crate::HttpEndpointSettings::new( + "endpoint", + enabled, + crate::HttpProviderName::new("provider"), + crate::HttpClusterName::new("devnet"), + url, + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(2), + std::option::Option::Some(4), + std::vec![role], + ); +} + +#[test] +fn endpoint_client_snapshot_never_contains_url_or_secret_material() { + let client = super::HttpEndpointClient::new(endpoint(true, "https://provider.invalid/rpc?api-key=SECRET-CANARY")).expect("client must build"); + let snapshot = client.snapshot(); + let rendered = format!("{snapshot:?} {client:?}"); + assert_eq!(snapshot.availability(), crate::HttpEndpointAvailability::Available); + assert!(!rendered.contains("SECRET-CANARY")); + assert!(!rendered.contains("provider.invalid")); + assert!(!rendered.contains("https://")); +} + +#[test] +fn disabled_endpoint_client_is_visible_but_not_selectable() { + let client = super::HttpEndpointClient::new(endpoint(false, "https://api.devnet.solana.com")).expect("disabled client must still build"); + assert_eq!(client.snapshot().availability(), crate::HttpEndpointAvailability::Disabled); + assert!(!client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))); +} + +#[test] +fn endpoint_client_matches_exact_and_wildcard_capabilities() { + let client = super::HttpEndpointClient::new(endpoint(true, "https://api.devnet.solana.com")).expect("client must build"); + assert!(client.supports(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance"))); + assert!(!client.supports(&crate::HttpRoleName::new("write"), &crate::HttpRequestKind::new("get_balance"))); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/pool.rs b/crates/ksp-onchain-transport-lib/unit_tests/pool.rs new file mode 100644 index 0000000..31f8d81 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/pool.rs @@ -0,0 +1,167 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/pool.rs +// version: 1 + +fn role(name: &str, priority: u32, request_kinds: std::vec::Vec) -> crate::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::HttpEndpointSettings { + return crate::HttpEndpointSettings::new( + name, + enabled, + crate::HttpProviderName::new("provider"), + crate::HttpClusterName::new("devnet"), + crate::HttpEndpointUrl::parse(format!("https://{name}.invalid/rpc?token=SECRET-CANARY")).expect("test URL must parse"), + std::time::Duration::from_secs(1), + std::time::Duration::from_secs(2), + std::option::Option::Some(4), + std::vec![role("default", priority, request_kinds)], + ); +} + +fn settings(endpoints: std::vec::Vec) -> crate::HttpTransportSettings { + return crate::HttpTransportSettings::new( + endpoints, + crate::HttpRetrySettings::new(2, std::time::Duration::from_millis(10), std::time::Duration::from_millis(50)), + ); +} + +#[test] +fn pool_prefers_lowest_priority_tier() { + let pool = super::HttpTransportPool::new(settings(std::vec![ + endpoint("secondary", true, 20, std::vec![crate::HttpRequestKind::wildcard()]), + endpoint("primary", true, 10, std::vec![crate::HttpRequestKind::wildcard()]), + ])) + .expect("pool must build"); + let selection = pool + .select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")) + .expect("selection must succeed"); + assert_eq!(selection.endpoint_name(), "primary"); + assert_eq!(selection.priority(), 10); +} + +#[test] +fn pool_round_robins_fairly_inside_best_priority_tier() { + let pool = super::HttpTransportPool::new(settings(std::vec![ + endpoint("one", true, 10, std::vec![crate::HttpRequestKind::wildcard()]), + endpoint("two", true, 10, std::vec![crate::HttpRequestKind::wildcard()]), + endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]), + ])) + .expect("pool must build"); + let role = crate::HttpRoleName::new("default"); + let kind = crate::HttpRequestKind::new("get_balance"); + let first = pool.select_for_request_kind(&role, &kind).expect("first selection must succeed"); + let second = pool.select_for_request_kind(&role, &kind).expect("second selection must succeed"); + let third = pool.select_for_request_kind(&role, &kind).expect("third selection must succeed"); + assert_eq!(first.endpoint_name(), "one"); + assert_eq!(second.endpoint_name(), "two"); + assert_eq!(third.endpoint_name(), "one"); +} + +#[test] +fn disabled_best_priority_endpoint_falls_back_to_next_tier() { + let pool = super::HttpTransportPool::new(settings(std::vec![ + endpoint("disabled-primary", false, 1, std::vec![crate::HttpRequestKind::wildcard()]), + endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]), + ])) + .expect("pool must build"); + let selection = pool + .select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")) + .expect("fallback must be selected"); + assert_eq!(selection.endpoint_name(), "fallback"); +} + +#[test] +fn pool_filters_role_and_capability_before_priority() { + let pool = super::HttpTransportPool::new(settings(std::vec![ + endpoint("wrong-capability", true, 1, std::vec![crate::HttpRequestKind::new("send_transaction")]), + endpoint("matching", true, 50, std::vec![crate::HttpRequestKind::new("get_balance")]), + ])) + .expect("pool must build"); + let selection = pool + .select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")) + .expect("matching capability must be selected"); + assert_eq!(selection.endpoint_name(), "matching"); +} + +#[test] +fn pool_returns_structured_error_when_no_endpoint_matches() { + let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("read-only", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)])) + .expect("pool must build"); + let error = pool + .select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("send_transaction")) + .expect_err("unsupported request kind must fail selection"); + assert_eq!(error.code(), crate::ERROR_CODE_ENDPOINT_SELECTION_FAILED); + assert!(!format!("{error:?}").contains("SECRET-CANARY")); +} + +#[test] +fn standard_method_selection_uses_registry_request_kind() { + let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("balance", true, 10, std::vec![crate::HttpRequestKind::new("get_balance")],)])) + .expect("pool must build"); + let method = crate::find_http_rpc_method("getBalance").expect("audited method must exist"); + let selection = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect("standard method must route"); + assert_eq!(selection.request_kind().as_str(), "get_balance"); +} + +#[test] +fn pool_snapshot_is_safe_and_preserves_disabled_endpoints() { + let pool = super::HttpTransportPool::new(settings(std::vec![ + endpoint("enabled", true, 10, std::vec![crate::HttpRequestKind::wildcard()]), + endpoint("disabled", false, 10, std::vec![crate::HttpRequestKind::wildcard()]), + ])) + .expect("pool must build"); + let snapshot = pool.snapshot(); + let rendered = format!("{snapshot:?} {pool:?}"); + assert_eq!(snapshot.endpoint_count(), 2); + assert_eq!(snapshot.available_endpoint_count(), 1); + assert!(!rendered.contains("SECRET-CANARY")); + assert!(!rendered.contains(".invalid/rpc")); +} + +#[test] +fn disabled_role_is_excluded_before_priority_selection() { + let base = endpoint("disabled-role", true, 1, std::vec![crate::HttpRequestKind::wildcard()]); + let disabled_role = crate::HttpEndpointRoleSettings::new( + crate::HttpRoleName::new("default"), + false, + std::vec![crate::HttpRequestKind::wildcard()], + 1, + crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None), + ); + let disabled_role_endpoint = crate::HttpEndpointSettings::new( + base.name(), + true, + base.provider().clone(), + base.cluster().clone(), + base.url().clone(), + base.connect_timeout(), + base.request_timeout(), + base.max_idle_connections_per_host(), + std::vec![disabled_role], + ); + let pool = super::HttpTransportPool::new(settings(std::vec![ + disabled_role_endpoint, + endpoint("fallback", true, 20, std::vec![crate::HttpRequestKind::wildcard()]), + ])) + .expect("pool must build"); + let selection = pool + .select_for_request_kind(&crate::HttpRoleName::new("default"), &crate::HttpRequestKind::new("get_balance")) + .expect("enabled fallback role must be selected"); + assert_eq!(selection.endpoint_name(), "fallback"); +} + +#[test] +fn removed_standard_method_is_rejected_before_endpoint_routing() { + let pool = super::HttpTransportPool::new(settings(std::vec![endpoint("wildcard", true, 10, std::vec![crate::HttpRequestKind::wildcard()],)])) + .expect("pool must build"); + let method = crate::find_http_rpc_method("confirmTransaction").expect("historical method must exist"); + let error = pool.select_for_method(&crate::HttpRoleName::new("default"), method).expect_err("removed standard method must be rejected before routing"); + assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED); +} diff --git a/deltas/0.2.1/pre.003.md b/deltas/0.2.1/pre.003.md new file mode 100644 index 0000000..cf6ce73 --- /dev/null +++ b/deltas/0.2.1/pre.003.md @@ -0,0 +1,196 @@ + + + +# Delta `v0.2.1-pre.003` + +## Base + +Base attendue : `v0.2.1-pre.002-fix.001`, validée localement avant ouverture de cette tranche. + +Version Cargo cible : + +```text +0.2.1-pre.3 +``` + +## Objectif + +Matérialiser la première couche de client/routing HTTP de `ksp-onchain-transport-lib` sans encore exécuter de méthode JSON-RPC : + +- client endpoint logique autour de `reqwest::Client` ; +- pool logique KSP ; +- matching rôle/capability ; +- priorité globale ; +- round-robin équitable dans un même tier ; +- fallback lorsque les candidats plus prioritaires ne sont pas sélectionnables ; +- snapshots sûrs sans URL ; +- préparation des états passifs nécessaires à la résilience de `pre.004`. + +Cette tranche corrige également l'usage de `ROADMAP.md` afin de respecter son contrat existant : le ROADMAP décrit l'état synthétique et la planification majeure, pas l'historique des prereleases/fixes. + +## Modifications + +### Workspace + +- `workspace.package.version` passe de `0.2.1-pre.2.fix.1` à `0.2.1-pre.3` ; +- `reqwest` reste centralisé sous `[workspace.dependencies]`, avec `default-features = false` ; +- activation explicite de la feature `rustls`, nécessaire à la construction réelle de clients HTTPS dans cette tranche ; +- aucune feature `json` n'est ajoutée : les envelopes JSON-RPC restent possédées par KSP via `serde_json` ; +- aucune dépendance Tokio directe n'est ajoutée à Transport dans cette tranche. + +### `HttpEndpointClient` + +Nouveau client endpoint logique : + +- encapsule un `reqwest::Client` partageable ; +- applique `connect_timeout`, `request_timeout` et `max_idle_connections_per_host` depuis les settings KSP ; +- désactive les redirects automatiques ; +- désactive les proxies système/environnement implicites ; +- fixe un `User-Agent` KSP `ksp-onchain-transport-lib/` ; +- conserve l'URL hors de toute surface `Debug`/snapshot ; +- expose uniquement identité, provider, cluster, état et capability matching sûrs. + +### Snapshots et état passif + +Nouveaux contrats publics : + +```text +HttpEndpointAvailability +HttpEndpointRoleSnapshot +HttpEndpointSnapshot +HttpTransportPoolSnapshot +``` + +Les snapshots ne contiennent jamais l'URL endpoint. + +`HttpEndpointAvailability` réserve : + +```text +Disabled +Available +Degraded +RateLimited +``` + +`pre.003` ne produit activement que `Disabled` et `Available`. Les transitions `Degraded` / `RateLimited` appartiennent à `pre.004` avec cooldown, limiter et observations runtime. + +### `HttpTransportPool` + +Nouveaux contrats publics : + +```text +HttpTransportPool +HttpEndpointSelection +``` + +Algorithme concret de `pre.003` : + +1. valider les settings Transport ; +2. construire un client logique par endpoint ; +3. exclure les endpoints disabled ; +4. rechercher un rôle exact enabled ; +5. exiger la capability exacte ou `*` ; +6. choisir la plus faible priorité numérique ; +7. appliquer un round-robin par couple rôle/request-kind dans ce meilleur tier ; +8. utiliser un tier moins prioritaire lorsque les candidats plus prioritaires ne sont pas sélectionnables ; +9. retourner `endpoint_selection_failed` si aucun candidat n'existe. + +Le mutex synchrone du pool protège uniquement les curseurs de fairness et ne couvre aucune I/O ni aucun `await` réseau. + +`select_for_method()` dérive le `request_kind` depuis le registre central et appelle `ensure_runtime_supported()` avant sélection ; une méthode historique `Removed` ne peut donc pas être routée comme méthode standard. + +### ROADMAP + +`ROADMAP.md` est ramené à son rôle défini par `FILE_CONTRACTS.md` : + +- suppression des lignes servant de journal `0.2.1-pre.*` / `fix.*` ; +- conservation d'un état synthétique de `0.2.0` ; +- état synthétique courant de `0.2.1` ; +- conservation des releases fonctionnelles `0.2.1+` et de leur planification majeure. + +Aucune règle normative nouvelle n'est nécessaire : cette correction applique le contrat `ROADMAP.md` déjà documenté. + +### Plan `008` + +Le plan est synchronisé avec : + +- la feature TLS réellement retenue ; +- la politique client `reqwest` ; +- le statut réalisé de `pre.003` ; +- l'état exact après la tranche ; +- le report explicite des limiteurs/concurrence/cooldown/retry effectifs à `pre.004`. + +## Tests ajoutés + +La crate Transport passe de 40 à **53 tests déclarés**. + +Nouvelles preuves : + +- snapshot client sans URL/secret ; +- endpoint disabled visible mais non sélectionnable ; +- matching rôle/wildcard capability ; +- priorité globale ; +- round-robin dans le meilleur tier ; +- fallback depuis un endpoint plus prioritaire disabled ; +- filtrage capability avant priorité ; +- erreur structurée lorsque rien ne correspond ; +- routing d'une méthode standard via son descriptor ; +- snapshot pool sûr conservant les endpoints disabled ; +- consommation du nouveau pool depuis l'API publique. + +## Hors périmètre conservé + +Restent à `pre.004` : + +- token bucket RPS/burst ; +- semaphore/max concurrent ; +- cooldown après `429` ; +- deadline effective de sélection/exécution ; +- retry/backoff effectif ; +- classification des erreurs `reqwest` pendant une requête ; +- transitions runtime `Degraded` / `RateLimited`. + +Restent à `pre.005+` : + +- exécution JSON-RPC HTTP ; +- méthodes canari typées ; +- document Config standard et adapter ; +- smoke tests réseau. + +## Sources externes revérifiées + +Pour `reqwest 0.13` : + +- dépôt/documentation officielle `reqwest` : rustls est le backend TLS de référence actuel ; +- changelog `reqwest` : en `0.13`, la feature historique `rustls-tls` a été renommée `rustls` ; +- `ClientBuilder` officiel : `connect_timeout`, `timeout`, `pool_max_idle_per_host`, `redirect` et `no_proxy` sont disponibles sur le client async retenu. + +## Validation + +Non exécutée dans le sandbox de génération : `cargo` et `rustc` n'y sont pas installés. + +À exécuter après application : + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-onchain-transport-lib +cargo test --workspace +cargo tree -p ksp-onchain-transport-lib +cargo tree -p ksp-onchain-transport-lib -d +cargo tree -p ksp-onchain-transport-lib -e features +cargo tree -p ksp-onchain-transport-lib -e normal +``` + +Les quatre `cargo tree` doivent être rejoués dans cette tranche car le feature-set de `reqwest` change avec l'activation de `rustls`. + +## Suite + +Tranche suivante prévue : + +```text +0.2.1-pre.004 +``` + +Périmètre : RPS/burst, concurrence, cooldown/429, timeout/deadline et retry/backoff borné, avec respect strict de `TransportRetryClass` et interdiction de resend après dispatch ambigu. diff --git a/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md b/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md index b9442e8..97b77b3 100644 --- a/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md +++ b/docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md @@ -1,5 +1,5 @@ - + # `0.2.1-pre.001` — plan `ksp-onchain-transport-lib` HTTP Solana foundation @@ -498,6 +498,18 @@ Pour la surface standard connue, l'exécuteur consulte le registry. Une méthode Le pool sélectionne un **endpoint/client HTTP logique**. Le pooling de sockets/keep-alive reste la responsabilité du client `reqwest` sous-jacent. +### 12.1.1 Politique client concrétisée par `pre.003` + +Chaque endpoint construit un `reqwest::Client` partageable qui conserve le pooling de sockets sous-jacent. Le builder KSP applique les `connect_timeout`, `request_timeout` et `max_idle_connections_per_host` déjà possédés par les settings. + +Décisions de sécurité/déterminisme : + +- backend HTTPS : feature `reqwest/rustls` explicite ; +- redirects automatiques désactivés : une URL provider éventuellement porteuse de credential n'est jamais redirigée implicitement vers une autre destination ; +- proxies système/environnement implicites désactivés via le builder `reqwest`; une future prise en charge de proxy devra être un contrat Config/Transport explicite ; +- `User-Agent` KSP stable : `ksp-onchain-transport-lib/` ; +- URL conservée uniquement dans les settings privés du client logique et absente des snapshots/`Debug` publics. + ### 12.2 Algorithme de sélection Pour une requête : @@ -706,7 +718,7 @@ ksp-core-lib path/workspace ksp-logging-lib path/workspace ``` -`pre.002` utilise `reqwest` uniquement pour le parsing/validation robuste de `HttpEndpointUrl`; aucune requête réseau n'est encore créée. Conformément à `RUST-DEP-001` / `RUST-DEP-003`, aucune feature TLS/JSON/client ni dépendance Tokio locale n'est activée par anticipation. Les features réellement nécessaires au client async sont décidées et ajoutées dans `pre.003`, lorsque le chemin de compilation HTTP existe. Le même principe reporte `tokio` à la première tranche qui utilise effectivement ses primitives runtime/sync. +`pre.002` utilisait `reqwest` uniquement pour le parsing/validation robuste de `HttpEndpointUrl`. `pre.003` crée réellement les clients HTTP logiques et active donc la feature `rustls` de `reqwest 0.13` au workspace, toujours avec `default-features = false`. Aucune feature `json` n'est nécessaire : KSP possède déjà son encodage JSON-RPC via `serde_json`. Aucune dépendance Tokio directe n'est ajoutée tant que `pre.004` n'utilise pas effectivement ses primitives de concurrence/attente. Ne pas ajouter : @@ -859,7 +871,7 @@ La release peut devenir stable seulement si : |-----------|----------------------------------------------------------------------------------------------------------------------| | `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` | endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs | +| `pre.003` | **réalisé** : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs | | `pre.004` | RPS/burst/concurrence/cooldown + timeout + retry/backoff + classification retry/no-resend | | `pre.005` | méthodes canari `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes | | `pre.006` | `std.transport` schema/document/example + registry Config + adapter Config -> Transport + sensibilité/env tests | @@ -882,7 +894,25 @@ Ce découpage est révisable si une tranche dépasse le budget ; la release réd - contrôle central `ensure_runtime_supported()` : warning KSP pour surfaces non stables supportées, warning + `method_removed` pour les historiques supprimées ; - 40 tests Rust ajoutés (unitaires + intégration), dont redaction, invariants JSON-RPC, matrice 4/22/11/15 et canary de firewall du manifest. -Les éléments suivants restent volontairement à `pre.003+` : construction `reqwest::Client`, clients logiques, pool, sélection/rôles/fallback, Tokio runtime/sync, TLS, rate limiting, concurrence et retry effectif. +Les éléments annoncés pour `pre.003` dans cet état historique sont désormais concrétisés au §22.2 ; rate limiting, concurrence et retry effectif restent reportés. + +### 22.2 État après `0.2.1-pre.003` + +`pre.003` matérialise la première couche de routing HTTP sans encore exécuter de JSON-RPC : + +- `workspace.package.version = 0.2.1-pre.3` ; +- `reqwest` reste `default-features = false` et active uniquement `rustls` pour rendre les endpoints HTTPS réellement constructibles ; +- `HttpEndpointClient` encapsule un `reqwest::Client`, applique connect/request timeout et limite idle-per-host, désactive redirect et proxy système implicites, et n'expose jamais l'URL dans `Debug`/snapshot ; +- `HttpEndpointSnapshot`, `HttpEndpointRoleSnapshot` et `HttpTransportPoolSnapshot` exposent uniquement identité/provider/cluster/routing/status sûrs ; +- `HttpTransportPool` valide les settings puis construit un client logique par endpoint ; +- sélection exacte rôle + capability, wildcard `*`, priorité globale croissante et round-robin par rôle/request-kind dans le meilleur tier ; +- endpoint ou rôle disabled exclus ; un tier inférieur devient donc fallback lorsqu'aucun candidat du tier supérieur n'est sélectionnable ; +- `select_for_method` dérive la capability depuis le registre RPC central et refuse une méthode historique `Removed` avant routing ; +- aucune mutex synchrone ne couvre un `await` réseau : le seul verrou actuel protège brièvement les curseurs de fairness, avant toute I/O ; +- le contrat d'availability réserve `Disabled`, `Available`, `Degraded`, `RateLimited`; `pre.003` ne produit que les deux premiers, les transitions runtime appartenant à `pre.004`. +- la suite Transport compte désormais 53 tests déclarés, dont les nouvelles preuves de redaction client/pool, priorité, fairness, fallback, capability et contrat public du pool. + +Restent volontairement à `pre.004` : token bucket RPS/burst, semaphore de concurrence, cooldown/429, deadline effective, retry/backoff et mutations passives `Degraded/RateLimited`. ## 23. Séquence `0.2.x` recalibrée