From be2a06bc80c245ac2c08b3f0bce006268558434f Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 25 Aug 2026 21:00:19 +0200 Subject: [PATCH] v0.2.11-pre.003 --- Cargo.toml | 4 +- crates/ksp-offchain-transport-lib/Cargo.toml | 6 +- .../ksp-offchain-transport-lib/src/error.rs | 26 +- .../src/http_admission.rs | 218 ++++++++++++ .../src/http_client.rs | 331 ++++++++++++++++++ .../src/http_settings.rs | 79 +++++ crates/ksp-offchain-transport-lib/src/lib.rs | 57 ++- .../tests/dependency_boundary.rs | 34 +- .../tests/public_api.rs | 16 +- .../unit_tests/http_admission.rs | 73 ++++ .../unit_tests/http_client.rs | 220 ++++++++++++ .../unit_tests/http_settings.rs | 20 ++ .../unit_tests/grpc_stream.rs | 5 +- .../unit_tests/grpc_unary.rs | 13 +- deltas/0.2.11/pre.003.md | 300 ++++++++++++++++ ...8-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md | 32 +- ...011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md | 1 - .../014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md | 25 +- 18 files changed, 1415 insertions(+), 45 deletions(-) create mode 100644 crates/ksp-offchain-transport-lib/src/http_admission.rs create mode 100644 crates/ksp-offchain-transport-lib/src/http_client.rs create mode 100644 crates/ksp-offchain-transport-lib/src/http_settings.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/http_client.rs create mode 100644 crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs create mode 100644 deltas/0.2.11/pre.003.md diff --git a/Cargo.toml b/Cargo.toml index e8bb5e9..0f25fab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 271 +# version: 272 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.2.11-pre.2.fix.3" +version = "0.2.11-pre.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-offchain-transport-lib/Cargo.toml b/crates/ksp-offchain-transport-lib/Cargo.toml index f4b9c51..c97ea8f 100644 --- a/crates/ksp-offchain-transport-lib/Cargo.toml +++ b/crates/ksp-offchain-transport-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-offchain-transport-lib/Cargo.toml -# version: 2 +# version: 3 [package] name = "ksp-offchain-transport-lib" @@ -10,10 +10,12 @@ repository.workspace = true [dependencies] ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } +reqwest = { workspace = true, features = ["rustls"] } serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true [dev-dependencies] -serde_json.workspace = true +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "time"] } [lints] workspace = true diff --git a/crates/ksp-offchain-transport-lib/src/error.rs b/crates/ksp-offchain-transport-lib/src/error.rs index d0d38ec..bc89973 100644 --- a/crates/ksp-offchain-transport-lib/src/error.rs +++ b/crates/ksp-offchain-transport-lib/src/error.rs @@ -1,6 +1,30 @@ // file: crates/ksp-offchain-transport-lib/src/error.rs -// version: 3 +// version: 4 +/// Stable off-chain transport error for an invalid crate-wide HTTP runtime configuration. +pub const ERROR_CODE_HTTP_SETTINGS_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_settings_invalid"); +/// Stable off-chain transport error when the hardened reqwest client cannot be initialized. +pub const ERROR_CODE_HTTP_CLIENT_BUILD_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_client_build_failed"); +/// Stable off-chain transport error for an invalid crate-owned HTTP request definition. +pub const ERROR_CODE_HTTP_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_invalid"); +/// Stable off-chain transport error for a connection failure without exposing the provider URL. +pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_connection_failed"); +/// Stable off-chain transport error for an end-to-end HTTP timeout. +pub const ERROR_CODE_HTTP_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_timeout"); +/// Stable off-chain transport error for a generic unsuccessful or transport-level HTTP request. +pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_request_failed"); +/// Stable off-chain transport error for HTTP 401/403 access denial. +pub const ERROR_CODE_HTTP_ACCESS_DENIED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_access_denied"); +/// Stable off-chain transport error for HTTP 429 rate limiting. +pub const ERROR_CODE_HTTP_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limited"); +/// Stable off-chain transport error for transient HTTP status failures such as 408 or 5xx. +pub const ERROR_CODE_HTTP_TEMPORARY_FAILURE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_temporary_failure"); +/// Stable off-chain transport error when a response exceeds the defensive body limit. +pub const ERROR_CODE_HTTP_RESPONSE_TOO_LARGE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_response_too_large"); +/// Stable off-chain transport error when a successful HTTP response is not syntactically valid JSON. +pub const ERROR_CODE_HTTP_INVALID_JSON: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_invalid_json"); +/// Stable off-chain transport error for an invalid local request-admission/rate-limit policy. +pub const ERROR_CODE_HTTP_RATE_LIMIT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "http_rate_limit_invalid"); /// Stable off-chain transport error for an invalid exact market-price decimal. pub const ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("offchain_transport", "market_price_decimal_invalid"); /// Stable off-chain transport error for an invalid normalized market-price observation. diff --git a/crates/ksp-offchain-transport-lib/src/http_admission.rs b/crates/ksp-offchain-transport-lib/src/http_admission.rs new file mode 100644 index 0000000..1ad0ef4 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/http_admission.rs @@ -0,0 +1,218 @@ +// file: crates/ksp-offchain-transport-lib/src/http_admission.rs +// version: 1 + +//! Provider-neutral local request admission and rate-limit cooldown primitives. + +/// Maximum provider-directed cooldown accepted from `Retry-After`. +pub(crate) const HTTP_MAX_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(3_600); + +const DEFAULT_RATE_LIMIT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(1); +const MAX_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3_600); + +/// Crate-internal local request-admission policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HttpAdmissionPolicy { + /// No stable local cadence is known; only provider-driven cooldown is enforced. + Dynamic, + /// Enforce a smooth token bucket for a documented request budget and window. + Fixed { requests: u32, window: std::time::Duration, burst: u32 }, + /// No local cadence limit is configured; provider-driven cooldown still applies. + Unlimited, +} + +impl HttpAdmissionPolicy { + /// Creates a validated fixed-window admission policy. + pub(crate) fn fixed(requests: u32, window: std::time::Duration, burst: std::option::Option) -> ksp_core_lib::Result { + let burst = match burst { + std::option::Option::Some(value) => value, + std::option::Option::None => 1, + }; + if requests == 0 || window.is_zero() || window > MAX_RATE_LIMIT_WINDOW || burst == 0 || burst > requests { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP request-admission policy is invalid") + .with_context("field", "rate_limit"), + ); + } + return std::result::Result::Ok(Self::Fixed { requests, window, burst }); + } +} + +/// Result of one non-blocking local admission attempt. +pub(crate) enum HttpAdmissionDecision { + /// The request may dispatch now and one local token has been consumed when applicable. + Ready, + /// The request must be deferred for at least this duration. + Deferred(std::time::Duration), +} + +/// Shared non-blocking limiter used by provider adapters and later refresh orchestration. +pub(crate) struct HttpAdmissionController { + cooldown_until: std::sync::Mutex>, + fallback_cooldown: std::time::Duration, + policy: HttpAdmissionPolicy, + token_bucket: std::sync::Mutex>, +} + +impl HttpAdmissionController { + /// Creates one limiter from a provider-owned local admission policy. + pub(crate) fn new(policy: HttpAdmissionPolicy, fallback_cooldown: std::option::Option) -> ksp_core_lib::Result { + let fallback_cooldown = match fallback_cooldown { + std::option::Option::Some(value) => value, + std::option::Option::None => DEFAULT_RATE_LIMIT_COOLDOWN, + }; + if fallback_cooldown.is_zero() || fallback_cooldown > HTTP_MAX_RETRY_AFTER { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "HTTP fallback cooldown is outside the supported bounds") + .with_context("field", "fallback_cooldown"), + ); + } + let token_bucket = match policy { + HttpAdmissionPolicy::Fixed { requests, window, burst } => { + std::option::Option::Some(HttpTokenBucketState::new(requests, window, burst, std::time::Instant::now())) + }, + HttpAdmissionPolicy::Dynamic | HttpAdmissionPolicy::Unlimited => std::option::Option::None, + }; + return std::result::Result::Ok(Self { + cooldown_until: std::sync::Mutex::new(std::option::Option::None), + fallback_cooldown, + policy, + token_bucket: std::sync::Mutex::new(token_bucket), + }); + } + + /// Returns the configured local policy. + #[must_use] + pub(crate) const fn policy(&self) -> HttpAdmissionPolicy { + return self.policy; + } + + /// Tries to admit one request immediately without sleeping. + pub(crate) fn try_admit(&self) -> HttpAdmissionDecision { + return self.try_admit_at(std::time::Instant::now()); + } + + /// Records a provider 429 and extends cooldown using a bounded `Retry-After` value when present. + pub(crate) fn record_rate_limited(&self, provider_retry_after: std::option::Option) -> std::time::Duration { + let provider_delay = match provider_retry_after { + std::option::Option::Some(value) => std::cmp::min(value, HTTP_MAX_RETRY_AFTER), + std::option::Option::None => std::time::Duration::ZERO, + }; + let effective = std::cmp::max(self.fallback_cooldown, provider_delay); + self.record_cooldown_until(std::time::Instant::now(), effective); + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + cooldown_ms = duration_millis_u64(effective), + provider_retry_after_present = provider_retry_after.is_some(), + "recorded off-chain HTTP provider cooldown" + ); + return effective; + } + + /// Returns the remaining provider cooldown, if any. + pub(crate) fn cooldown_remaining(&self) -> std::option::Option { + return self.cooldown_remaining_at(std::time::Instant::now()); + } + + fn try_admit_at(&self, now: std::time::Instant) -> HttpAdmissionDecision { + if let std::option::Option::Some(remaining) = self.cooldown_remaining_at(now) { + return HttpAdmissionDecision::Deferred(remaining); + } + let lock_result = self.token_bucket.lock(); + let mut token_bucket = match lock_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(poisoned) => poisoned.into_inner(), + }; + let state = match token_bucket.as_mut() { + std::option::Option::Some(value) => value, + std::option::Option::None => return HttpAdmissionDecision::Ready, + }; + return match state.try_consume_at(now) { + std::option::Option::Some(delay) => HttpAdmissionDecision::Deferred(delay), + std::option::Option::None => HttpAdmissionDecision::Ready, + }; + } + + fn cooldown_remaining_at(&self, now: std::time::Instant) -> std::option::Option { + let lock_result = self.cooldown_until.lock(); + let mut cooldown_until = match lock_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(poisoned) => poisoned.into_inner(), + }; + let deadline = match *cooldown_until { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::option::Option::None, + }; + if deadline <= now { + *cooldown_until = std::option::Option::None; + return std::option::Option::None; + } + return std::option::Option::Some(deadline.duration_since(now)); + } + + fn record_cooldown_until(&self, now: std::time::Instant, delay: std::time::Duration) { + let candidate = match now.checked_add(delay) { + std::option::Option::Some(value) => value, + std::option::Option::None => now, + }; + let lock_result = self.cooldown_until.lock(); + let mut cooldown_until = match lock_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(poisoned) => poisoned.into_inner(), + }; + let replace = match *cooldown_until { + std::option::Option::Some(current) => candidate > current, + std::option::Option::None => true, + }; + if replace { + *cooldown_until = std::option::Option::Some(candidate); + } + return; + } +} + +#[derive(Debug)] +struct HttpTokenBucketState { + available_tokens: f64, + burst: u32, + last_refill: std::time::Instant, + refill_per_second: f64, +} + +impl HttpTokenBucketState { + fn new(requests: u32, window: std::time::Duration, burst: u32, now: std::time::Instant) -> Self { + let refill_per_second = f64::from(requests) / window.as_secs_f64(); + return Self { available_tokens: f64::from(burst), burst, last_refill: now, refill_per_second }; + } + + fn try_consume_at(&mut self, now: std::time::Instant) -> std::option::Option { + self.refill_at(now); + if self.available_tokens >= 1.0 { + self.available_tokens -= 1.0; + return std::option::Option::None; + } + let missing = 1.0 - self.available_tokens; + let wait_seconds = missing / self.refill_per_second; + return std::option::Option::Some(std::time::Duration::from_secs_f64(wait_seconds)); + } + + fn refill_at(&mut self, now: std::time::Instant) { + if now <= self.last_refill { + return; + } + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.available_tokens = (self.available_tokens + elapsed * self.refill_per_second).min(f64::from(self.burst)); + self.last_refill = now; + return; + } +} + +fn duration_millis_u64(duration: std::time::Duration) -> u64 { + return match u64::try_from(duration.as_millis()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => u64::MAX, + }; +} + +#[cfg(test)] +#[path = "../unit_tests/http_admission.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/http_client.rs b/crates/ksp-offchain-transport-lib/src/http_client.rs new file mode 100644 index 0000000..f6b8cad --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/http_client.rs @@ -0,0 +1,331 @@ +// file: crates/ksp-offchain-transport-lib/src/http_client.rs +// version: 1 + +//! Crate-wide hardened REST client used internally by off-chain capability adapters. + +/// Bounded successful JSON document returned by the crate-internal REST client. +pub(crate) struct HttpJsonDocument { + bytes: std::vec::Vec, +} + +impl HttpJsonDocument { + /// Returns the validated raw JSON bytes for provider-specific typed deserialization. + #[must_use] + pub(crate) fn as_bytes(&self) -> &[u8] { + return self.bytes.as_slice(); + } +} + +impl std::fmt::Debug for HttpJsonDocument { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.debug_struct("HttpJsonDocument").field("byte_len", &self.bytes.len()).finish(); + } +} + +/// Crate-internal fixed-origin GET request. +/// +/// URLs and headers are deliberately absent from [`std::fmt::Debug`] because future provider adapters can attach credentials to headers. +pub(crate) struct HttpGetRequest { + headers: reqwest::header::HeaderMap, + url: reqwest::Url, +} + +impl HttpGetRequest { + /// Creates one HTTPS GET request from a crate-owned official provider URL. + pub(crate) fn new_https(url: &'static str) -> ksp_core_lib::Result { + return Self::parse(url, false); + } + + /// Appends one non-secret query pair using URL encoding. + pub(crate) fn append_query_pair(&mut self, name: &'static str, value: &str) { + self.url.query_pairs_mut().append_pair(name, value); + return; + } + + /// Adds one sensitive header without exposing its value through this type's debug representation. + pub(crate) fn insert_sensitive_header(&mut self, name: &'static str, value: &str) -> ksp_core_lib::Result<()> { + let name_result = reqwest::header::HeaderName::from_bytes(name.as_bytes()); + let name = match name_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header name is invalid") + .with_context("field", "header_name") + .with_source(error), + ); + }, + }; + let value_result = reqwest::header::HeaderValue::from_bytes(value.as_bytes()); + let mut value = match value_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain HTTP header value is invalid") + .with_context("field", "header_value") + .with_source(error), + ); + }, + }; + value.set_sensitive(true); + self.headers.insert(name, value); + return std::result::Result::Ok(()); + } + + /// Creates a plain-HTTP request for loopback-only deterministic unit tests. + #[cfg(test)] + pub(crate) fn new_test_http(url: &str) -> ksp_core_lib::Result { + return Self::parse(url, true); + } + + fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result { + let parsed_result = reqwest::Url::parse(url); + let parsed = match parsed_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL is invalid") + .with_context("field", "provider_url") + .with_source(error), + ); + }, + }; + let scheme_allowed = parsed.scheme() == "https" || (allow_http_for_tests && parsed.scheme() == "http"); + if !scheme_allowed || parsed.host_str().is_none() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL must use an allowed scheme and host") + .with_context("field", "provider_url"), + ); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot embed credentials") + .with_context("field", "provider_url"), + ); + } + return std::result::Result::Ok(Self { headers: reqwest::header::HeaderMap::new(), url: parsed }); + } +} + +impl std::fmt::Debug for HttpGetRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter.write_str("HttpGetRequest()"); + } +} + +/// Shareable hardened REST client owned by Off-chain Transport. +#[derive(Clone)] +pub(crate) struct HttpRestClient { + client: reqwest::Client, + settings: crate::HttpClientSettings, +} + +impl HttpRestClient { + /// Builds one hardened client with redirects, system proxies and reqwest automatic retries disabled. + pub(crate) fn new(settings: crate::HttpClientSettings) -> ksp_core_lib::Result { + let client_result = reqwest::Client::builder() + .connect_timeout(settings.connect_timeout()) + .timeout(settings.request_timeout()) + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .retry(reqwest::retry::never()) + .no_proxy() + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + .build(); + let client = match client_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, "Off-chain HTTP client could not be initialized") + .with_source(error.without_url()), + ); + }, + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + connect_timeout_ms = duration_millis_u64(settings.connect_timeout()), + request_timeout_ms = duration_millis_u64(settings.request_timeout()), + max_response_body_bytes = settings.max_response_body_bytes(), + "created hardened off-chain HTTP REST client" + ); + return std::result::Result::Ok(Self { client, settings }); + } + + /// Executes one GET request and returns only a bounded syntactically valid JSON document. + pub(crate) async fn get_json(&self, provider: &'static str, operation: &'static str, request: HttpGetRequest) -> ksp_core_lib::Result { + let send_result = self.client.get(request.url).headers(request.headers).send().await; + let mut response = match send_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)), + }; + let status = response.status().as_u16(); + let retry_after = parse_retry_after(response.headers()); + if !(200..300).contains(&status) { + return classify_http_status(provider, operation, status, retry_after); + } + if let std::option::Option::Some(content_length) = response.content_length() + && content_length > usize_to_u64(self.settings.max_response_body_bytes()) + { + return response_too_large(provider, operation, self.settings.max_response_body_bytes()); + } + let mut body = std::vec::Vec::new(); + loop { + let chunk_result = response.chunk().await; + let chunk = match chunk_result { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => break, + std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(provider, operation, error)), + }; + let next_len = body.len().saturating_add(chunk.len()); + if next_len > self.settings.max_response_body_bytes() { + return response_too_large(provider, operation, self.settings.max_response_body_bytes()); + } + body.extend_from_slice(chunk.as_ref()); + } + let json_validation = serde_json::from_slice::(body.as_slice()); + if let std::result::Result::Err(error) = json_validation { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "Off-chain provider returned invalid JSON") + .with_context("provider", provider) + .with_context("operation", operation) + .with_source(error), + ); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + provider = provider, + operation = operation, + http_status = status, + response_body_bytes = body.len(), + "completed off-chain HTTP REST request" + ); + return std::result::Result::Ok(HttpJsonDocument { bytes: body }); + } +} + +fn classify_http_status( + provider: &'static str, + operation: &'static str, + status: u16, + retry_after: std::option::Option, +) -> ksp_core_lib::Result { + if status == 401 || status == 403 { + return std::result::Result::Err(http_status_error( + crate::ERROR_CODE_HTTP_ACCESS_DENIED, + "Off-chain provider denied HTTP access", + provider, + operation, + status, + std::option::Option::None, + )); + } + if status == 429 { + return std::result::Result::Err(http_status_error( + crate::ERROR_CODE_HTTP_RATE_LIMITED, + "Off-chain provider rate-limited the request", + provider, + operation, + status, + retry_after, + )); + } + if status == 408 || (500..600).contains(&status) { + return std::result::Result::Err(http_status_error( + crate::ERROR_CODE_HTTP_TEMPORARY_FAILURE, + "Off-chain provider returned a temporary HTTP failure", + provider, + operation, + status, + retry_after, + )); + } + return std::result::Result::Err(http_status_error( + crate::ERROR_CODE_HTTP_REQUEST_FAILED, + "Off-chain provider returned an unsuccessful HTTP status", + provider, + operation, + status, + std::option::Option::None, + )); +} + +fn http_status_error( + code: ksp_core_lib::ErrorCode, + message: &'static str, + provider: &'static str, + operation: &'static str, + status: u16, + retry_after: std::option::Option, +) -> ksp_core_lib::Error { + let mut error = ksp_core_lib::Error::new(code, message) + .with_context("provider", provider) + .with_context("operation", operation) + .with_context("http_status", status.to_string()); + if let std::option::Option::Some(value) = retry_after { + error = error.with_context("retry_after_seconds", value.as_secs().to_string()); + } + return error; +} + +fn map_reqwest_error(provider: &'static str, operation: &'static str, error: reqwest::Error) -> ksp_core_lib::Error { + let code = if error.is_timeout() { + crate::ERROR_CODE_HTTP_TIMEOUT + } else if error.is_connect() { + crate::ERROR_CODE_HTTP_CONNECTION_FAILED + } else { + crate::ERROR_CODE_HTTP_REQUEST_FAILED + }; + let message = if code == crate::ERROR_CODE_HTTP_TIMEOUT { + "Off-chain HTTP request timed out" + } else if code == crate::ERROR_CODE_HTTP_CONNECTION_FAILED { + "Off-chain HTTP connection failed" + } else { + "Off-chain HTTP request failed" + }; + return ksp_core_lib::Error::new(code, message) + .with_context("provider", provider) + .with_context("operation", operation) + .with_source(error.without_url()); +} + +fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option { + 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(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + let seconds = match text.parse::() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::option::Option::None, + }; + return std::option::Option::Some(std::cmp::min(std::time::Duration::from_secs(seconds), crate::HTTP_MAX_RETRY_AFTER)); +} + +fn response_too_large(provider: &'static str, operation: &'static str, limit: usize) -> ksp_core_lib::Result { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "Off-chain provider response exceeded the configured body limit") + .with_context("provider", provider) + .with_context("operation", operation) + .with_context("max_response_body_bytes", limit.to_string()), + ); +} + +fn duration_millis_u64(duration: std::time::Duration) -> u64 { + return match u64::try_from(duration.as_millis()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => u64::MAX, + }; +} + +fn usize_to_u64(value: usize) -> u64 { + return match u64::try_from(value) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => u64::MAX, + }; +} + +#[cfg(test)] +#[path = "../unit_tests/http_client.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/http_settings.rs b/crates/ksp-offchain-transport-lib/src/http_settings.rs new file mode 100644 index 0000000..c8e196c --- /dev/null +++ b/crates/ksp-offchain-transport-lib/src/http_settings.rs @@ -0,0 +1,79 @@ +// file: crates/ksp-offchain-transport-lib/src/http_settings.rs +// version: 1 + +//! Crate-wide bounded HTTP runtime settings shared by off-chain capability families. + +const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 1_048_576; +const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const MAX_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const MAX_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); +const MAX_RESPONSE_BODY_BYTES: usize = 4_194_304; + +/// Crate-internal HTTP client settings with defensive hard bounds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct HttpClientSettings { + connect_timeout: std::time::Duration, + max_response_body_bytes: usize, + request_timeout: std::time::Duration, +} + +impl HttpClientSettings { + /// Creates one validated HTTP settings value. + pub(crate) fn new( + connect_timeout: std::time::Duration, + request_timeout: std::time::Duration, + max_response_body_bytes: usize, + ) -> ksp_core_lib::Result { + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return invalid_http_settings("HTTP connect timeout is outside the supported bounds", "connect_timeout"); + } + if request_timeout.is_zero() || request_timeout > MAX_REQUEST_TIMEOUT { + return invalid_http_settings("HTTP request timeout is outside the supported bounds", "request_timeout"); + } + if connect_timeout > request_timeout { + return invalid_http_settings("HTTP connect timeout cannot exceed the total request timeout", "connect_timeout"); + } + if max_response_body_bytes == 0 || max_response_body_bytes > MAX_RESPONSE_BODY_BYTES { + return invalid_http_settings("HTTP response-body limit is outside the supported bounds", "max_response_body_bytes"); + } + return std::result::Result::Ok(Self { connect_timeout, max_response_body_bytes, request_timeout }); + } + + /// Returns the bounded connect timeout. + #[must_use] + pub(crate) const fn connect_timeout(&self) -> std::time::Duration { + return self.connect_timeout; + } + + /// Returns the maximum decoded response body accepted before JSON parsing. + #[must_use] + pub(crate) const fn max_response_body_bytes(&self) -> usize { + return self.max_response_body_bytes; + } + + /// Returns the end-to-end request timeout. + #[must_use] + pub(crate) const fn request_timeout(&self) -> std::time::Duration { + return self.request_timeout; + } +} + +impl std::default::Default for HttpClientSettings { + fn default() -> Self { + return Self { + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + max_response_body_bytes: DEFAULT_MAX_RESPONSE_BODY_BYTES, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + }; + } +} + +fn invalid_http_settings(message: &str, field: &'static str) -> ksp_core_lib::Result { + ksp_logging_lib::warn!(target: crate::TRACING_TARGET, field = field, "rejected invalid off-chain HTTP client settings"); + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_SETTINGS_INVALID, message).with_context("field", field)); +} + +#[cfg(test)] +#[path = "../unit_tests/http_settings.rs"] +mod tests; diff --git a/crates/ksp-offchain-transport-lib/src/lib.rs b/crates/ksp-offchain-transport-lib/src/lib.rs index 90e1ea1..08c7c84 100644 --- a/crates/ksp-offchain-transport-lib/src/lib.rs +++ b/crates/ksp-offchain-transport-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-offchain-transport-lib/src/lib.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,19 +7,48 @@ //! KSP-owned off-chain transport foundation. //! -//! `0.2.11-pre.002` opens the first deliberately narrow capability family: `market_price`, currently limited to exact SOL/USD values, provider-neutral -//! observations, provider descriptors, common provider settings and generic availability/rate-limit metadata. The crate itself is deliberately broader than -//! market prices: future off-chain capabilities such as amount-specific swap quotes belong to separate capability families instead of extending the -//! `market_price_*` modules. Shared transport/runtime concerns stay crate-wide. No provider wire type, HTTP client, provider SDK, Config dependency or active -//! rate limiter is introduced by this tranche. +//! `0.2.11-pre.003` keeps the first capability family deliberately narrow (`market_price`, SOL/USD only) while materializing crate-wide hardened HTTP REST +//! primitives and non-blocking local admission/cooldown machinery. These `http_*` internals are shared transport mechanics for future off-chain capability +//! families such as `swap_quote_*`; they are intentionally not re-exported as a generic consumer HTTP client. Provider wire DTOs, provider adapters, Config +//! integration and refresh orchestration remain outside this tranche. mod constants; mod error; +#[allow(dead_code)] // Staged in pre.003 and consumed by provider adapters starting in pre.004. +mod http_admission; +#[allow(dead_code)] // Staged in pre.003 and consumed by provider adapters starting in pre.004. +mod http_client; +#[allow(dead_code)] // Staged in pre.003 and consumed by provider adapters starting in pre.004. +mod http_settings; mod market_price_decimal; mod market_price_observation; mod market_price_provider; mod market_price_settings; +/// Stable error code for HTTP access denial. +pub use self::error::ERROR_CODE_HTTP_ACCESS_DENIED; +/// Stable error code for hardened HTTP client initialization failure. +pub use self::error::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED; +/// Stable error code for an off-chain HTTP connection failure. +pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED; +/// Stable error code for a syntactically invalid JSON response. +pub use self::error::ERROR_CODE_HTTP_INVALID_JSON; +/// Stable error code for an invalid local HTTP rate-limit policy. +pub use self::error::ERROR_CODE_HTTP_RATE_LIMIT_INVALID; +/// Stable error code for HTTP 429 rate limiting. +pub use self::error::ERROR_CODE_HTTP_RATE_LIMITED; +/// Stable error code for a generic unsuccessful HTTP request. +pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED; +/// Stable error code for an invalid crate-owned HTTP request definition. +pub use self::error::ERROR_CODE_HTTP_REQUEST_INVALID; +/// Stable error code for an oversized HTTP response body. +pub use self::error::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE; +/// Stable error code for invalid crate-wide HTTP runtime settings. +pub use self::error::ERROR_CODE_HTTP_SETTINGS_INVALID; +/// Stable error code for temporary HTTP provider failures. +pub use self::error::ERROR_CODE_HTTP_TEMPORARY_FAILURE; +/// Stable error code for end-to-end HTTP timeout. +pub use self::error::ERROR_CODE_HTTP_TIMEOUT; /// Stable error code for an invalid exact decimal price. pub use self::error::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID; /// Stable error code for an invalid provider-neutral observation. @@ -79,3 +108,19 @@ pub use self::market_price_settings::MarketPriceProviderCommonSettings; /// Owning tracing target for events emitted by Off-chain Transport. pub(crate) use self::constants::TRACING_TARGET; +/// Maximum provider-directed cooldown accepted from a server `Retry-After` value. +pub(crate) use self::http_admission::HTTP_MAX_RETRY_AFTER; +/// Crate-internal non-blocking request-admission controller. +pub(crate) use self::http_admission::HttpAdmissionController; +/// Crate-internal result of one immediate request-admission attempt. +pub(crate) use self::http_admission::HttpAdmissionDecision; +/// Crate-internal provider-neutral local request-admission policy. +pub(crate) use self::http_admission::HttpAdmissionPolicy; +/// Crate-internal fixed-origin GET request with redacted diagnostics. +pub(crate) use self::http_client::HttpGetRequest; +/// Crate-internal bounded syntactically valid JSON response document. +pub(crate) use self::http_client::HttpJsonDocument; +/// Crate-internal hardened REST client shared by capability adapters. +pub(crate) use self::http_client::HttpRestClient; +/// Crate-internal bounded HTTP runtime settings. +pub(crate) use self::http_settings::HttpClientSettings; diff --git a/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs index ff47819..529c0f2 100644 --- a/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +++ b/crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs @@ -1,20 +1,21 @@ // file: crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] -//! Dependency, observability and module-taxonomy canaries for the Off-chain Transport foundation. +//! Dependency, observability, hardened HTTP and module-taxonomy canaries for Off-chain Transport. #[test] -fn pre_002_manifest_keeps_foundation_provider_and_config_independent() { +fn pre_003_manifest_adds_only_shared_http_runtime_dependencies() { let manifest = include_str!("../Cargo.toml"); assert!(manifest.contains("ksp-core-lib")); assert!(manifest.contains("ksp-logging-lib")); + assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }")); assert!(manifest.contains("serde")); + assert!(manifest.contains("serde_json.workspace = true")); assert!(!manifest.contains("ksp-config-lib")); - assert!(!manifest.contains("reqwest")); assert!(!manifest.contains("coingecko")); assert!(!manifest.contains("coinmarketcap")); assert!(!manifest.contains("jupiter")); @@ -22,15 +23,30 @@ fn pre_002_manifest_keeps_foundation_provider_and_config_independent() { assert!(!manifest.contains("dexscreener")); assert!(!manifest.lines().any(|line| return line.trim_start().starts_with("tracing ="))); let crate_root = include_str!("../src/lib.rs"); - assert!(crate_root.contains("mod constants;")); + assert!(crate_root.contains("mod http_admission;")); + assert!(crate_root.contains("mod http_client;")); + assert!(crate_root.contains("mod http_settings;")); assert!(crate_root.contains("mod market_price_decimal;")); assert!(crate_root.contains("mod market_price_observation;")); assert!(crate_root.contains("mod market_price_provider;")); assert!(crate_root.contains("mod market_price_settings;")); - assert!(!crate_root.contains("mod decimal;")); - assert!(!crate_root.contains("mod observation;")); - assert!(!crate_root.contains("mod provider;")); - assert!(!crate_root.contains("mod settings;")); + assert!(!crate_root.contains("pub use self::http_client::HttpRestClient")); let constants = include_str!("../src/constants.rs"); assert!(constants.contains("pub(crate) const TRACING_TARGET: &str = \"ksp-offchain-transport-lib\";")); } + +#[test] +fn pre_003_http_client_hardening_is_explicit_and_provider_neutral() { + let client = include_str!("../src/http_client.rs"); + assert!(client.contains(".redirect(reqwest::redirect::Policy::none())")); + assert!(client.contains(".referer(false)")); + assert!(client.contains(".retry(reqwest::retry::never())")); + assert!(client.contains(".no_proxy()")); + assert!(client.contains("error.without_url()")); + assert!(client.contains("response.chunk().await")); + assert!(!client.contains("coingecko")); + assert!(!client.contains("coinmarketcap")); + assert!(!client.contains("jupiter")); + assert!(!client.contains("birdeye")); + assert!(!client.contains("dexscreener")); +} diff --git a/crates/ksp-offchain-transport-lib/tests/public_api.rs b/crates/ksp-offchain-transport-lib/tests/public_api.rs index a904c46..baf00fc 100644 --- a/crates/ksp-offchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-offchain-transport-lib/tests/public_api.rs @@ -1,11 +1,11 @@ // file: crates/ksp-offchain-transport-lib/tests/public_api.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] -//! Public API canaries for the first Off-chain Transport market-price capability family. +//! Public API canaries for the market-price foundation and stable Off-chain Transport error-code surface. #[test] fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_core_lib::Result<()> { @@ -68,6 +68,18 @@ fn public_pre_002_market_price_foundation_is_available_from_crate_root() -> ksp_ #[test] fn offchain_error_codes_use_owned_domain() { let codes = [ + ksp_offchain_transport_lib::ERROR_CODE_HTTP_ACCESS_DENIED, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_CLIENT_BUILD_FAILED, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_CONNECTION_FAILED, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_INVALID_JSON, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_RATE_LIMITED, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_FAILED, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_REQUEST_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_SETTINGS_INVALID, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_TEMPORARY_FAILURE, + ksp_offchain_transport_lib::ERROR_CODE_HTTP_TIMEOUT, ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_DECIMAL_INVALID, ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_DESCRIPTOR_INVALID, ksp_offchain_transport_lib::ERROR_CODE_MARKET_PRICE_PROVIDER_ID_INVALID, diff --git a/crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs b/crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs new file mode 100644 index 0000000..64b73f4 --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs @@ -0,0 +1,73 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs +// version: 1 + +#[test] +fn fixed_admission_smooths_undocumented_burst_and_refills_deterministically() -> ksp_core_lib::Result<()> { + let policy = match crate::HttpAdmissionPolicy::fixed(2, std::time::Duration::from_secs(1), std::option::Option::None) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let start = std::time::Instant::now(); + assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready)); + let deferred = controller.try_admit_at(start); + let delay = match deferred { + crate::HttpAdmissionDecision::Deferred(value) => value, + crate::HttpAdmissionDecision::Ready => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test expected local deferral")); + }, + }; + assert!(delay > std::time::Duration::ZERO); + let half_second = match start.checked_add(std::time::Duration::from_millis(500)) { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMIT_INVALID, "test instant overflow")); + }, + }; + assert!(matches!(controller.try_admit_at(half_second), crate::HttpAdmissionDecision::Ready)); + return std::result::Result::Ok(()); +} + +#[test] +fn documented_burst_is_consumed_atomically_before_refill() -> ksp_core_lib::Result<()> { + let policy = match crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(2)) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let controller = match crate::HttpAdmissionController::new(policy, std::option::Option::None) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let start = std::time::Instant::now(); + assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready)); + assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Ready)); + assert!(matches!(controller.try_admit_at(start), crate::HttpAdmissionDecision::Deferred(_))); + return std::result::Result::Ok(()); +} + +#[test] +fn provider_retry_after_extends_but_cannot_pathologically_lock_cooldown() -> ksp_core_lib::Result<()> { + let controller = + match crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Dynamic, std::option::Option::Some(std::time::Duration::from_secs(2))) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(controller.policy(), crate::HttpAdmissionPolicy::Dynamic); + let applied = controller.record_rate_limited(std::option::Option::Some(std::time::Duration::from_secs(99_999))); + assert_eq!(applied, crate::HTTP_MAX_RETRY_AFTER); + let remaining = controller.cooldown_remaining(); + assert!(remaining.is_some()); + assert!(matches!(controller.try_admit(), crate::HttpAdmissionDecision::Deferred(_))); + return std::result::Result::Ok(()); +} + +#[test] +fn admission_policy_rejects_zero_values_and_invalid_fallback() { + assert!(crate::HttpAdmissionPolicy::fixed(0, std::time::Duration::from_secs(1), std::option::Option::None).is_err()); + assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::ZERO, std::option::Option::None).is_err()); + assert!(crate::HttpAdmissionPolicy::fixed(1, std::time::Duration::from_secs(1), std::option::Option::Some(0)).is_err()); + assert!(crate::HttpAdmissionController::new(crate::HttpAdmissionPolicy::Unlimited, std::option::Option::Some(std::time::Duration::ZERO)).is_err()); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/http_client.rs b/crates/ksp-offchain-transport-lib/unit_tests/http_client.rs new file mode 100644 index 0000000..55b36aa --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/http_client.rs @@ -0,0 +1,220 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/http_client.rs +// version: 1 + +#[tokio::test] +async fn rest_client_accepts_bounded_json_and_never_exposes_request_debug() -> ksp_core_lib::Result<()> { + let server_result = + spawn_single_response("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\n{\"price\":123}").await; + let server = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + request.append_query_pair("symbol", "SOL/USD"); + let secret = "sensitive-canary-value"; + if let std::result::Result::Err(error) = request.insert_sensitive_header("x-api-key", secret) { + return std::result::Result::Err(error); + } + let debug = format!("{request:?}"); + assert!(!debug.contains(secret)); + assert!(!debug.contains(server.url.as_str())); + let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let document = match client.get_json("test-provider", "sol_usd", request).await { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + assert_eq!(document.as_bytes(), br#"{"price":123}"#); + if let std::result::Result::Err(error) = finish_server(server.join).await { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +#[tokio::test] +async fn rest_client_rejects_redirects_instead_of_following_them() -> ksp_core_lib::Result<()> { + let server_result = + spawn_single_response("HTTP/1.1 302 Found\r\nLocation: https://example.invalid/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await; + let server = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let error = match client.get_json("test-provider", "redirect", request).await { + std::result::Result::Ok(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test expected redirect rejection")); + }, + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED); + if let std::result::Result::Err(error) = finish_server(server.join).await { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +#[tokio::test] +async fn rest_client_bounds_chunked_body_before_json_deserialization() -> ksp_core_lib::Result<()> { + let server_result = spawn_single_response(concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Type: application/json\r\n", + "Transfer-Encoding: chunked\r\n", + "Connection: close\r\n\r\n", + "8\r\n{\"aaaa\":\r\n", + "8\r\n\"bbbbbb\"\r\n", + "1\r\n}\r\n0\r\n\r\n", + )) + .await; + let server = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let settings = match crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 8) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let client = match crate::HttpRestClient::new(settings) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let error = match client.get_json("test-provider", "bounded", request).await { + std::result::Result::Ok(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE, "test expected body bound")); + }, + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RESPONSE_TOO_LARGE); + if let std::result::Result::Err(error) = finish_server(server.join).await { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +#[tokio::test] +async fn rest_client_classifies_429_retry_after_and_never_copies_remote_body() -> ksp_core_lib::Result<()> { + let remote_canary = "REMOTE_SECRET_CANARY"; + let response = + format!("HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", remote_canary.len(), remote_canary); + let server_result = spawn_single_response(response.as_str()).await; + let server = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let error = match client.get_json("test-provider", "rate_limited", request).await { + std::result::Result::Ok(_) => return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_RATE_LIMITED, "test expected 429")), + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_HTTP_RATE_LIMITED); + assert!(!format!("{error:?}").contains(remote_canary)); + assert!(!error.to_string().contains(remote_canary)); + if let std::result::Result::Err(error) = finish_server(server.join).await { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +#[tokio::test] +async fn rest_client_rejects_invalid_json_after_success_status() -> ksp_core_lib::Result<()> { + let server_result = spawn_single_response("HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nnot-json").await; + let server = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let request = match crate::HttpGetRequest::new_test_http(server.url.as_str()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let client = match crate::HttpRestClient::new(crate::HttpClientSettings::default()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let error = match client.get_json("test-provider", "invalid_json", request).await { + std::result::Result::Ok(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_INVALID_JSON, "test expected invalid JSON")); + }, + std::result::Result::Err(error) => error, + }; + assert_eq!(error.code(), crate::ERROR_CODE_HTTP_INVALID_JSON); + if let std::result::Result::Err(error) = finish_server(server.join).await { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +struct TestServer { + join: tokio::task::JoinHandle>, + url: std::string::String, +} + +async fn spawn_single_response(response: &str) -> ksp_core_lib::Result { + let listener_result = tokio::net::TcpListener::bind("127.0.0.1:0").await; + let listener = match listener_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)), + }; + let address = match listener.local_addr() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(io_error(error)), + }; + let response = response.as_bytes().to_vec(); + let join = tokio::spawn(async move { + let accept_result = listener.accept().await; + let (mut socket, _) = match accept_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mut request = [0_u8; 4096]; + if let std::result::Result::Err(error) = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await { + return std::result::Result::Err(error); + } + if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_slice()).await { + return std::result::Result::Err(error); + } + if let std::result::Result::Err(error) = tokio::io::AsyncWriteExt::shutdown(&mut socket).await { + return std::result::Result::Err(error); + } + return std::io::Result::Ok(()); + }); + return std::result::Result::Ok(TestServer { join, url: format!("http://{address}/price") }); +} + +async fn finish_server(join: tokio::task::JoinHandle>) -> ksp_core_lib::Result<()> { + let joined = join.await; + return match joined { + std::result::Result::Ok(std::result::Result::Ok(())) => std::result::Result::Ok(()), + std::result::Result::Ok(std::result::Result::Err(error)) => std::result::Result::Err(io_error(error)), + std::result::Result::Err(error) => std::result::Result::Err(join_error(error)), + }; +} + +fn io_error(error: std::io::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server failed").with_source(error); +} + +fn join_error(error: tokio::task::JoinError) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_FAILED, "test HTTP server task failed").with_source(error); +} diff --git a/crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs b/crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs new file mode 100644 index 0000000..9bae09a --- /dev/null +++ b/crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs @@ -0,0 +1,20 @@ +// file: crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs +// version: 1 + +#[test] +fn http_settings_defaults_are_bounded_and_ordered() { + let settings = crate::HttpClientSettings::default(); + assert!(!settings.connect_timeout().is_zero()); + assert!(settings.connect_timeout() <= settings.request_timeout()); + assert!(settings.max_response_body_bytes() > 0); +} + +#[test] +fn http_settings_reject_zero_reversed_and_pathological_bounds() { + assert!(crate::HttpClientSettings::new(std::time::Duration::ZERO, std::time::Duration::from_secs(1), 1024).is_err()); + assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(2), std::time::Duration::from_secs(1), 1024).is_err()); + assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 0).is_err()); + assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(31), std::time::Duration::from_secs(31), 1024).is_err()); + assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(121), 1024).is_err()); + assert!(crate::HttpClientSettings::new(std::time::Duration::from_secs(1), std::time::Duration::from_secs(2), 4_194_305).is_err()); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs b/crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs index 016bb8d..537c6ac 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_stream.rs -// version: 2 +// version: 3 #[derive(Clone, Copy)] enum FixtureMode { @@ -27,7 +27,6 @@ struct FixtureGeyser { #[tonic::async_trait] impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser { type SubscribeStream = super::MpscStream>; - type SubscribeDeshredStream = futures_util::stream::Empty>; async fn subscribe( &self, @@ -163,6 +162,8 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser { return std::result::Result::Ok(tonic::Response::new(super::MpscStream::new(outbound_rx))); } + type SubscribeDeshredStream = futures_util::stream::Empty>; + async fn subscribe_deshred( &self, _request: tonic::Request>, diff --git a/crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs b/crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs index 9322b96..0becfad 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/unit_tests/grpc_unary.rs -// version: 2 +// version: 3 #[derive(Clone, Default)] struct FixtureGeyser; @@ -10,11 +10,6 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser { type SubscribeStream = std::pin::Pin< std::boxed::Box> + Send + 'static>, >; - type SubscribeDeshredStream = std::pin::Pin< - std::boxed::Box< - dyn futures_util::Stream> + Send + 'static, - >, - >; async fn subscribe( &self, @@ -23,6 +18,12 @@ impl yellowstone_grpc_proto::geyser::geyser_server::Geyser for FixtureGeyser { return std::result::Result::Err(tonic::Status::unimplemented("streaming is outside the pre.003 fixture")); } + type SubscribeDeshredStream = std::pin::Pin< + std::boxed::Box< + dyn futures_util::Stream> + Send + 'static, + >, + >; + async fn subscribe_deshred( &self, _request: tonic::Request>, diff --git a/deltas/0.2.11/pre.003.md b/deltas/0.2.11/pre.003.md new file mode 100644 index 0000000..57b7a43 --- /dev/null +++ b/deltas/0.2.11/pre.003.md @@ -0,0 +1,300 @@ + + + +# Delta `0.2.11-pre.003` — HTTP REST commun et rate limiting + +## 1. Base requise + +Cette tranche s'applique exclusivement après : + +```text +v0.2.10 ++ 0.2.11-pre.001 ++ 0.2.11-pre.001-fix.001 ++ 0.2.11-pre.002 ++ 0.2.11-pre.002-fix.001 ++ 0.2.11-pre.002-fix.002 ++ 0.2.11-pre.002-fix.003 +``` + +La version Cargo attendue à l'entrée est : + +```text +0.2.11-pre.2.fix.3 +``` + +La version Cargo de sortie est : + +```text +0.2.11-pre.3 +``` + +## 2. Gate d'entrée acquis + +L'opérateur a validé `0.2.11-pre.002-fix.003` le `2026-08-25` avec : + +```text +cargo fmt --all exécuté +python3 scripts/audit_rust_workspace_rules.py clean +python3 scripts/audit_markdown_tables.py clean, 116 tables / 105 files +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-offchain-transport-lib PASS, 10 unit + 1 boundary + 2 public API +``` + +`cargo test --workspace` n'a pas été fourni pour cet état exact et n'est pas déclaré PASS ici. + +## 3. Objectif + +Matérialiser les primitives HTTP REST communes de `ksp-offchain-transport-lib` et le rate limiting local sans avancer aucun provider. + +La tranche doit préparer directement `pre.004+` tout en conservant les frontières : + +```text +http_* = mécanique transport crate-wide et crate-private +market_price_* = première capacité métier, SOL/USD uniquement +aucun client HTTP générique exporté aux consumers +aucun SDK provider +aucun Config -> Off-chain ajouté avant pre.009 +aucun refresh registry/service avant pre.007/pre.008 +``` + +## 4. HTTP REST commun + +`http_client.rs` matérialise un `reqwest::Client` réutilisable par les adapters internes. + +La construction impose explicitement : + +```text +rustls +redirects désactivés +Referer automatique désactivé +proxy système désactivé +retries implicites reqwest désactivés +User-Agent KSP explicite +connect timeout borné +request timeout borné +``` + +`reqwest` reste une dépendance directe de la crate et aucun SDK provider n'est ajouté. + +La requête GET interne : + +```text +accepte uniquement HTTPS pour les adapters runtime +refuse credentials dans l'URL +encode les query pairs via Url +permet des headers sensibles marqués sensitive +redacte URL et headers dans Debug +``` + +Un constructeur HTTP non HTTPS existe uniquement sous `cfg(test)` pour les serveurs loopback déterministes. + +## 5. Bornes de réponse et JSON + +Les valeurs initiales sont : + +```text +connect timeout défaut = 5 s +connect timeout hard max = 30 s +request timeout défaut = 10 s +request timeout hard max = 120 s +body défaut = 1 MiB +body hard max = 4 MiB +``` + +Le body est borné pendant sa lecture par chunks. La présence ou l'absence de `Content-Length` ne permet donc pas de contourner la limite. + +Après lecture, la syntaxe JSON est validée avec `serde::de::IgnoredAny`. Le document brut borné reste disponible pour le futur adapter typed, ce qui évite de faire passer un nombre provider par une représentation générique `f64` avant le parsing `MarketPriceDecimal`. + +## 6. Classification HTTP + +Les codes crate-wide ajoutés sous le domaine `offchain_transport` distinguent : + +```text +http_settings_invalid +http_client_build_failed +http_request_invalid +http_connection_failed +http_timeout +http_request_failed +http_access_denied +http_rate_limited +http_temporary_failure +http_response_too_large +http_invalid_json +http_rate_limit_invalid +``` + +La classification de statut est : + +```text +401/403 -> access denied +429 -> rate limited +408 et 5xx -> temporary failure +autre non-2xx -> request failed +2xx -> body borné puis JSON syntaxiquement validé +``` + +Les erreurs `reqwest` sont converties après `without_url()`. Aucun body remote n'est recopié dans `KspError`. + +`Retry-After` est actuellement exploité sous sa forme `delta-seconds`, bornée défensivement à une heure. Une forme HTTP-date non parseable reste ignorée plutôt que devinée. + +## 7. Admission et cooldown + +`http_admission.rs` fournit un limiter non bloquant. + +Les policies internes sont : + +```text +Fixed +Dynamic +Unlimited +``` + +Une policy fixe utilise un token bucket lissé : + +```text +requests/window +burst explicite uniquement lorsqu'il est documenté +burst absent -> capacité locale conservatrice de 1 +``` + +Une policy dynamique n'invente aucune cadence locale. Elle applique seulement les cooldowns appris du provider. + +`try_admit()` ne dort jamais : il retourne immédiatement `Ready` ou `Deferred(duration)`. Cette propriété prépare le `refresh multiple/all` de `pre.008`, qui devra continuer avec les autres providers au lieu d'attendre un provider en cooldown. + +Un `429` peut étendre le cooldown avec `Retry-After`; la durée provider est bornée à une heure et comparée au fallback local. + +## 8. Tests ajoutés + +Les nouveaux tests unitaires couvrent : + +```text +settings HTTP par défaut et bornes pathologiques +policy fixe invalide +burst absent lissé conservativement +burst documenté consommé atomiquement +refill déterministe du token bucket +cooldown provider et borne Retry-After +Debug request sans URL/credential +GET JSON valide +redirect refusé +body chunked dépassant la limite +429 + Retry-After sans propagation du body canari +JSON invalide après statut 2xx +``` + +Les canaries d'intégration vérifient aussi : + +```text +reqwest rustls présent +Config toujours absent +SDK providers toujours absents +tracing direct toujours absent +modules http_* présents +HttpRestClient non exporté publiquement +redirect/no-proxy/no-retry/without_url explicites dans le client +codes d'erreur HTTP dans le domaine offchain_transport +``` + +## 9. Fichiers ajoutés + +```text +crates/ksp-offchain-transport-lib/src/http_admission.rs +crates/ksp-offchain-transport-lib/src/http_client.rs +crates/ksp-offchain-transport-lib/src/http_settings.rs +crates/ksp-offchain-transport-lib/unit_tests/http_admission.rs +crates/ksp-offchain-transport-lib/unit_tests/http_client.rs +crates/ksp-offchain-transport-lib/unit_tests/http_settings.rs +deltas/0.2.11/pre.003.md +``` + +## 10. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-offchain-transport-lib/Cargo.toml +crates/ksp-offchain-transport-lib/src/error.rs +crates/ksp-offchain-transport-lib/src/lib.rs +crates/ksp-offchain-transport-lib/tests/dependency_boundary.rs +crates/ksp-offchain-transport-lib/tests/public_api.rs +docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +``` + +## 11. Fichiers supprimés + +```text +aucun +``` + +## 12. Fichiers volontairement inchangés + +```text +CHANGELOG.md +ROADMAP.md +README.md +.env.example +config/** +crates/ksp-config-lib/** +crates/ksp-onchain-transport-lib/** +prompts/** +``` + +`ROADMAP.md` et `CHANGELOG.md` restent hors de la tranche conformément à leur ownership de release. + +## 13. Validations exécutées dans le sandbox + +```text +python3 scripts/audit_rust_workspace_rules.py + General Rust rule audit: clean + Rust export completeness audit: 0 candidate(s) + KSP workspace Rust rule audit: clean + +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11 + clean + +contrôle lignes Rust > 160 sur ksp-offchain-transport-lib + PASS + +inspection diff contre 0.2.11-pre.002-fix.003 + aucun provider adapter ajouté + aucune dépendance ksp-config-lib ajoutée + aucune dépendance tracing directe ajoutée +``` + +## 14. Validations non exécutées dans le sandbox + +Le sandbox de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`. + +Après application, exécuter : + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.11 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-offchain-transport-lib +cargo test --workspace +``` + +## 15. Décisions et points différés + +Aucune question ne bloque `pre.004`. + +Restent volontairement différés : + +```text +mapping MarketPriceProviderRateLimit -> HttpAdmissionPolicy par provider +CoinGecko/CoinMarketCap/CoinPaprika wire DTOs et endpoints +credentials provider effectifs +registry et availability runtime +refresh single/multiple +Config std.offchain_transport +Retry-After HTTP-date si un provider retenu l'exige réellement +POST/écritures HTTP pour futures familles off-chain +``` + +`pre.004` reste propriétaire du premier lot d'adapters : CoinGecko, CoinMarketCap et CoinPaprika. diff --git a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md index af3e22a..3599336 100644 --- a/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md +++ b/docs/plans/018-V0_2_11_OFFCHAIN_PRICE_TRANSPORT_PLAN.md @@ -1,9 +1,9 @@ - + # Plan `0.2.11` — Off-chain price transport SOL/USD multi-provider -**Statut courant : `0.2.11-pre.002-fix.003` ferme le nommage public de la famille `market_price` avant `pre.003`. Les types, constantes et codes d'erreur publics spécifiques à cette famille portent désormais le préfixe `MarketPrice` / `MARKET_PRICE`, afin d'éviter une collision future avec `swap_quote_*` ou une autre capacité off-chain. Le contrat V1 reste limité à SOL/USD et aucun client HTTP/provider n'est encore ajouté.** +**Statut courant : `0.2.11-pre.003` matérialise le HTTP REST commun et le rate limiting provider-neutral sans encore ajouter d'adapter provider. Les primitives `http_*` restent internes à la crate et réutilisables par les futures familles off-chain ; `market_price_*` reste la seule famille métier actuelle et demeure limitée à SOL/USD.** ## 1. Base et autorité @@ -482,24 +482,30 @@ Décisions V1 : ```text reqwest uniquement -HTTPS officiel fixe lorsque le provider le supporte +HTTPS officiel fixe dans les adapters V1 redirect désactivé -proxy implicite désactivé selon le pattern KSP retenu -connect timeout borné -request timeout borné +Referer automatique désactivé +proxy système/implicite désactivé +retries implicites reqwest désactivés +connect timeout borné : défaut 5 s, hard max 30 s +request timeout borné : défaut 10 s, hard max 120 s GET uniquement pour les prix V1 -body réponse borné -JSON attendu et validé +body réponse borné avant désérialisation : défaut 1 MiB, hard max 4 MiB +JSON syntaxiquement validé avant remise à l'adapter typed 429 classé explicitement -Retry-After honoré lorsqu'il est exploitable -5xx classé transitoire -401/403 classé auth/access +Retry-After delta-seconds honoré et borné à 1 h lorsqu'il est exploitable +408 et 5xx classés transitoires +401/403 classés auth/access URL retirée des erreurs reqwest avant contexte KSP aucun body remote brut dans KspError ``` Il n'existe pas d'URL provider arbitraire dans la Config V1. Cela évite SSRF, redirection de credential et confusion de provenance. +Les primitives `http_*` sont **crate-private** : elles ne créent pas un client HTTP générique public contournant les capacités métier. Les adapters `market_price_*`, puis de futures familles telles que `swap_quote_*`, les consomment derrière leur propre contrat. + +Le limiter `pre.003` est non bloquant. Une cadence fixe est matérialisée par un token bucket lissé ; lorsqu'aucun burst n'est documenté, la capacité locale initiale reste volontairement `1`. Une limite dynamique n'invente aucune cadence locale et apprend seulement des réponses provider, notamment `429`. Un `Retry-After` serveur peut prolonger le cooldown mais ne peut pas dépasser une borne défensive d'une heure. Aucune primitive HTTP commune ne dort en attendant la disponibilité : elle expose un délai de defer que l'orchestrateur `pre.008` pourra projeter provider par provider. + ## 15. Config provider-capability-aware Config reste l'unique propriétaire des documents/env/secrets. @@ -686,9 +692,9 @@ Préfixage de toute la surface publique spécifique à la famille `market_price` ### `pre.003` — HTTP REST commun et rate limiting -**Statut : planifié.** +**Statut : réalisé.** -Client HTTP REST commun, classification d'erreurs, bornes de body et timeouts, redaction, limiter et cooldown génériques. +Client HTTP REST commun crate-private, classification d'erreurs HTTP/reqwest, bornes de body et timeouts, redaction, désactivation explicite des redirects/proxy/retries implicites, JSON syntaxiquement validé, limiter token-bucket générique non bloquant et cooldown provider-driven borné. Aucun adapter provider n'est avancé. ### `pre.004` — CoinGecko, CoinMarketCap et CoinPaprika diff --git a/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md b/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md index efcf49b..eceb1f8 100644 --- a/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md +++ b/docs/validation/011-V0_2_8_HELIUS_LASERSTREAM_WEBSOCKET.md @@ -1157,4 +1157,3 @@ warnings 0 ``` Après validation de `rel.001`, le tag unique de la release est `v0.2.8` et `prompts/014-V0_2_9_START_PROMPT.md` devient le contrat actif pour `0.2.9-pre.001`. - diff --git a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md index 9fa781a..171d104 100644 --- a/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md +++ b/docs/validation/014-V0_2_11_OFFCHAIN_PRICE_TRANSPORT.md @@ -1,5 +1,5 @@ - + # Validation `0.2.11` — Off-chain price transport SOL/USD @@ -103,6 +103,29 @@ ERROR_CODE_MARKET_PRICE_* Cette correction reste dans la responsabilité de `pre.002` : elle stabilise la fondation et sa façade avant l'introduction du HTTP runtime en `pre.003`. +## 3.4 Gate `0.2.11-pre.003` + +| Critère | Statut | Preuve | +|-----------------------------------------------------------------------|---------|-----------------------------------------------------| +| version workspace `0.2.11-pre.3` | PASS | `Cargo.toml` racine | +| `reqwest` utilisé directement, sans SDK provider | PASS | manifest `ksp-offchain-transport-lib` | +| primitives HTTP communes sous modules `http_*` crate-private | PASS | `http_client`, `http_settings`, `http_admission` | +| client HTTP générique non exporté aux consumers | PASS | façade `lib.rs` | +| redirects, Referer, proxy système et retries reqwest désactivés | PASS | construction `HttpRestClient` | +| connect/request timeouts bornés | PASS | `HttpClientSettings` | +| body borné pendant lecture chunked avant désérialisation | PASS | `HttpRestClient::get_json` | +| JSON syntaxiquement validé sans normalisation numérique intermédiaire | PASS | `serde::de::IgnoredAny` sur bytes bruts | +| 401/403, 429, 408/5xx et autres statuts distingués | PASS | codes d'erreur HTTP dédiés | +| URL reqwest supprimée des sources d'erreur | PASS | `reqwest::Error::without_url` | +| body remote non recopié dans les erreurs | PASS | erreurs construites uniquement depuis metadata safe | +| limiter fixe lissé et burst absent traité conservativement | PASS | `HttpAdmissionController` | +| limite dynamique n'invente pas de cadence locale | PASS | `HttpAdmissionPolicy::Dynamic` | +| cooldown `Retry-After` borné et admission non bloquante | PASS | `record_rate_limited` + `try_admit` | +| adapters CoinGecko/Jupiter/etc. ajoutés | N/A | explicitement réservés à `pre.004+` | +| gate Cargo complet sur l'état livré | PENDING | à exécuter par l'opérateur | + +Les tests déterministes de `pre.003` couvrent en particulier le body chunked dépassant la limite, le refus des redirects, un `429` avec `Retry-After`, la non-propagation d'un body distant canari, le JSON invalide et le refill/cooldown du limiter. + ## 4. Matrice provider prévue | Provider | SOL/USD V1 | Gratuit V1 | Mode auth prévu | Test déterministe | Smoke live | Statut courant |