// file: crates/ksp-onchain-transport-lib/src/settings.rs // version: 6 /// Runtime HTTP endpoint URL owned by Transport. /// /// The actual URL can contain provider credentials. Its [`std::fmt::Debug`] implementation is intentionally redacted. #[derive(Clone, Eq, PartialEq)] pub struct HttpEndpointUrl { value: std::string::String, } impl HttpEndpointUrl { /// Parses and validates one HTTP or HTTPS endpoint URL. pub fn parse(value: impl std::convert::Into) -> ksp_core_lib::Result { let value = value.into(); let parsed_result = reqwest::Url::parse(value.as_str()); let parsed = match parsed_result { std::result::Result::Ok(parsed) => parsed, std::result::Result::Err(error) => { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL is invalid") .with_context("field", "endpoints.url") .with_source(error), ); }, }; if parsed.scheme() != "http" && parsed.scheme() != "https" { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must use http or https") .with_context("field", "endpoints.url") .with_context("scheme", parsed.scheme()), ); } if parsed.host_str().is_none() { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint URL must contain a host").with_context("field", "endpoints.url"), ); } return std::result::Result::Ok(Self { value }); } /// Returns the sensitive runtime URL text. /// /// Callers must not write this value to logs, generic diagnostics or snapshots. #[must_use] pub fn as_str(&self) -> &str { return self.value.as_str(); } } impl std::fmt::Debug for HttpEndpointUrl { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { return formatter.write_str("HttpEndpointUrl()"); } } /// Open provider descriptor used by HTTP endpoint settings. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct HttpProviderName { value: std::string::String, } impl HttpProviderName { /// Creates an open provider descriptor. Validation is performed by [`HttpTransportSettings::validate`]. #[must_use] pub fn new(value: impl std::convert::Into) -> Self { return Self { value: value.into() }; } /// Returns the provider descriptor text. #[must_use] pub fn as_str(&self) -> &str { return self.value.as_str(); } } /// Open cluster or network descriptor used by HTTP endpoint settings. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct HttpClusterName { value: std::string::String, } impl HttpClusterName { /// Creates an open cluster descriptor. Validation is performed by [`HttpTransportSettings::validate`]. #[must_use] pub fn new(value: impl std::convert::Into) -> Self { return Self { value: value.into() }; } /// Returns the cluster descriptor text. #[must_use] pub fn as_str(&self) -> &str { return self.value.as_str(); } } /// Open logical endpoint role descriptor. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct HttpRoleName { value: std::string::String, } impl HttpRoleName { /// Creates an open role descriptor. Validation is performed by [`HttpTransportSettings::validate`]. #[must_use] pub fn new(value: impl std::convert::Into) -> Self { return Self { value: value.into() }; } /// Returns the role descriptor text. #[must_use] pub fn as_str(&self) -> &str { return self.value.as_str(); } } /// Open request-kind descriptor used by logical endpoint capabilities. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct HttpRequestKind { value: std::string::String, } impl HttpRequestKind { /// Creates an open request-kind descriptor. `*` is reserved as the wildcard accepted by all standard request kinds. #[must_use] pub fn new(value: impl std::convert::Into) -> Self { return Self { value: value.into() }; } /// Creates the wildcard request-kind descriptor. #[must_use] pub fn wildcard() -> Self { return Self::new("*"); } /// Returns the request-kind descriptor text. #[must_use] pub fn as_str(&self) -> &str { return self.value.as_str(); } /// Returns whether this descriptor is the wildcard capability. #[must_use] pub fn is_wildcard(&self) -> bool { return self.value == "*"; } } /// Local limits attached to one logical HTTP endpoint role. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpRoleLimits { requests_per_second: std::option::Option, burst_capacity: std::option::Option, max_concurrent_requests: std::option::Option, pause_after_rate_limit: std::option::Option, } impl HttpRoleLimits { /// Creates explicit role limits. /// /// When RPS is configured and burst capacity is absent, runtime burst defaults to one second of RPS capacity. An absent concurrency limit is /// unbounded by this KSP transport layer. An absent rate-limit cooldown uses the Transport runtime fallback cooldown. #[must_use] pub const fn new( requests_per_second: std::option::Option, burst_capacity: std::option::Option, max_concurrent_requests: std::option::Option, pause_after_rate_limit: std::option::Option, ) -> Self { return Self { requests_per_second, burst_capacity, max_concurrent_requests, pause_after_rate_limit }; } /// Returns the configured requests-per-second limit. #[must_use] pub const fn requests_per_second(&self) -> std::option::Option { return self.requests_per_second; } /// Returns the configured token-bucket burst capacity. `None` means the runtime derives capacity from configured RPS. #[must_use] pub const fn burst_capacity(&self) -> std::option::Option { return self.burst_capacity; } /// Returns the configured maximum concurrent request count. #[must_use] pub const fn max_concurrent_requests(&self) -> std::option::Option { return self.max_concurrent_requests; } /// Returns the configured cooldown applied after rate limiting. `None` delegates to the Transport runtime fallback cooldown. #[must_use] pub const fn pause_after_rate_limit(&self) -> std::option::Option { return self.pause_after_rate_limit; } } /// Bounded retry settings owned by the HTTP transport runtime. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpRetrySettings { max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration, } impl HttpRetrySettings { /// Creates bounded retry settings. #[must_use] pub const fn new(max_retries: u32, initial_backoff: std::time::Duration, max_backoff: std::time::Duration) -> Self { return Self { max_retries, initial_backoff, max_backoff }; } /// Returns the number of retries allowed after the initial attempt. #[must_use] pub const fn max_retries(&self) -> u32 { return self.max_retries; } /// Returns the initial retry backoff. #[must_use] pub const fn initial_backoff(&self) -> std::time::Duration { return self.initial_backoff; } /// Returns the maximum retry backoff. #[must_use] pub const fn max_backoff(&self) -> std::time::Duration { return self.max_backoff; } } /// Runtime settings for one role declared by an HTTP endpoint. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpEndpointRoleSettings { role: crate::HttpRoleName, enabled: bool, request_kinds: std::vec::Vec, priority: u32, limits: crate::HttpRoleLimits, } impl HttpEndpointRoleSettings { /// Creates explicit settings for one logical endpoint role. #[must_use] pub fn new( role: crate::HttpRoleName, enabled: bool, request_kinds: std::vec::Vec, priority: u32, limits: crate::HttpRoleLimits, ) -> Self { return Self { role, enabled, request_kinds, priority, limits }; } /// Returns the open logical role descriptor. #[must_use] pub const fn role(&self) -> &crate::HttpRoleName { return &self.role; } /// Returns whether this role participates in endpoint selection. #[must_use] pub const fn enabled(&self) -> bool { return self.enabled; } /// Returns request kinds supported by this role. #[must_use] pub fn request_kinds(&self) -> &[crate::HttpRequestKind] { return self.request_kinds.as_slice(); } /// Returns the role priority where lower values are preferred. #[must_use] pub const fn priority(&self) -> u32 { return self.priority; } /// Returns local rate, burst and concurrency limits. #[must_use] pub const fn limits(&self) -> &crate::HttpRoleLimits { return &self.limits; } } /// Runtime settings for one named Solana HTTP endpoint. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpEndpointSettings { name: std::string::String, enabled: bool, provider: crate::HttpProviderName, cluster: crate::HttpClusterName, url: crate::HttpEndpointUrl, connect_timeout: std::time::Duration, request_timeout: std::time::Duration, max_idle_connections_per_host: std::option::Option, roles: std::vec::Vec, } impl HttpEndpointSettings { /// Creates explicit runtime settings for one logical HTTP endpoint. #[must_use] pub fn new( name: impl std::convert::Into, enabled: bool, provider: crate::HttpProviderName, cluster: crate::HttpClusterName, url: crate::HttpEndpointUrl, connect_timeout: std::time::Duration, request_timeout: std::time::Duration, max_idle_connections_per_host: std::option::Option, roles: std::vec::Vec, ) -> Self { return Self { name: name.into(), enabled, provider, cluster, url, connect_timeout, request_timeout, max_idle_connections_per_host, roles, }; } /// Returns the endpoint identity used by selection and safe diagnostics. #[must_use] pub fn name(&self) -> &str { return self.name.as_str(); } /// Returns whether this endpoint participates in endpoint selection. #[must_use] pub const fn enabled(&self) -> bool { return self.enabled; } /// Returns the provider descriptor. #[must_use] pub const fn provider(&self) -> &crate::HttpProviderName { return &self.provider; } /// Returns the cluster descriptor. #[must_use] pub const fn cluster(&self) -> &crate::HttpClusterName { return &self.cluster; } /// Returns the sensitive endpoint URL wrapper. #[must_use] pub const fn url(&self) -> &crate::HttpEndpointUrl { return &self.url; } /// Returns the connection-establishment timeout. #[must_use] pub const fn connect_timeout(&self) -> std::time::Duration { return self.connect_timeout; } /// Returns the end-to-end request timeout used by this endpoint. #[must_use] pub const fn request_timeout(&self) -> std::time::Duration { return self.request_timeout; } /// Returns the optional per-host idle connection pool limit. #[must_use] pub const fn max_idle_connections_per_host(&self) -> std::option::Option { return self.max_idle_connections_per_host; } /// Returns endpoint roles in declaration order. #[must_use] pub fn roles(&self) -> &[crate::HttpEndpointRoleSettings] { return self.roles.as_slice(); } } /// Complete runtime settings consumed by the Solana HTTP transport foundation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HttpTransportSettings { endpoints: std::vec::Vec, retry: crate::HttpRetrySettings, } impl HttpTransportSettings { /// Creates complete HTTP transport runtime settings. #[must_use] pub fn new(endpoints: std::vec::Vec, retry: crate::HttpRetrySettings) -> Self { return Self { endpoints, retry }; } /// Returns configured endpoints in declaration order. #[must_use] pub fn endpoints(&self) -> &[crate::HttpEndpointSettings] { return self.endpoints.as_slice(); } /// Returns the bounded transport retry settings. #[must_use] pub const fn retry(&self) -> &crate::HttpRetrySettings { return &self.retry; } /// Validates structural runtime invariants without reading Config or environment state. pub fn validate(&self) -> ksp_core_lib::Result<()> { let retry_validation = validate_retry(self.retry()); if let std::result::Result::Err(error) = retry_validation { return std::result::Result::Err(error); } if self.endpoints.is_empty() { return invalid_settings("at least one HTTP endpoint must be configured", "endpoints"); } let mut enabled_endpoint_count = 0_usize; for (endpoint_index, endpoint) in self.endpoints.iter().enumerate() { let endpoint_validation = validate_endpoint(endpoint, endpoint_index); if let std::result::Result::Err(error) = endpoint_validation { return std::result::Result::Err(error); } if endpoint.enabled() { enabled_endpoint_count += 1; } for previous in &self.endpoints[..endpoint_index] { if previous.name() == endpoint.name() { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint names must be unique") .with_context("field", format!("endpoints[{endpoint_index}].name")) .with_context("endpoint_name", endpoint.name()), ); } } } if enabled_endpoint_count == 0 { return invalid_settings("at least one HTTP endpoint must be enabled", "endpoints.enabled"); } ksp_logging_lib::debug!( target: crate::TRACING_TARGET, endpoint_count = self.endpoints.len(), enabled_endpoint_count, "validated HTTP transport settings" ); return std::result::Result::Ok(()); } } /// Validates endpoint settings. 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"); } if retry.max_backoff().is_zero() { return invalid_settings("maximum retry backoff must be greater than zero", "retry.max_backoff"); } if retry.max_backoff() < retry.initial_backoff() { return invalid_settings("maximum retry backoff must not be lower than initial retry backoff", "retry.max_backoff"); } return std::result::Result::Ok(()); } fn validate_endpoint(endpoint: &crate::HttpEndpointSettings, endpoint_index: usize) -> ksp_core_lib::Result<()> { let endpoint_name_validation = validate_descriptor(endpoint.name(), format!("endpoints[{endpoint_index}].name").as_str()); if let std::result::Result::Err(error) = endpoint_name_validation { return std::result::Result::Err(error); } let provider_validation = validate_descriptor(endpoint.provider().as_str(), format!("endpoints[{endpoint_index}].provider").as_str()); if let std::result::Result::Err(error) = provider_validation { return std::result::Result::Err(error); } let cluster_validation = validate_descriptor(endpoint.cluster().as_str(), format!("endpoints[{endpoint_index}].cluster").as_str()); if let std::result::Result::Err(error) = cluster_validation { return std::result::Result::Err(error); } if endpoint.connect_timeout().is_zero() { return invalid_settings("HTTP connect timeout must be greater than zero", format!("endpoints[{endpoint_index}].connect_timeout").as_str()); } if endpoint.request_timeout().is_zero() { return invalid_settings("HTTP request timeout must be greater than zero", format!("endpoints[{endpoint_index}].request_timeout").as_str()); } if let std::option::Option::Some(max_idle) = endpoint.max_idle_connections_per_host() && max_idle == 0 { return invalid_settings( "max idle connections per host must be greater than zero when configured", format!("endpoints[{endpoint_index}].max_idle_connections_per_host").as_str(), ); } if endpoint.roles().is_empty() { return invalid_settings("HTTP endpoint must declare at least one role", format!("endpoints[{endpoint_index}].roles").as_str()); } let mut enabled_role_count = 0_usize; for (role_index, role) in endpoint.roles().iter().enumerate() { let role_validation = validate_role(role, endpoint_index, role_index); if let std::result::Result::Err(error) = role_validation { return std::result::Result::Err(error); } if role.enabled() { enabled_role_count += 1; } for previous in &endpoint.roles()[..role_index] { if previous.role() == role.role() { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP endpoint role names must be unique per endpoint") .with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].role")) .with_context("endpoint_name", endpoint.name()) .with_context("role", role.role().as_str()), ); } } } if endpoint.enabled() && enabled_role_count == 0 { return invalid_settings("enabled HTTP endpoint must expose at least one enabled role", format!("endpoints[{endpoint_index}].roles.enabled").as_str()); } return std::result::Result::Ok(()); } fn validate_role(role: &crate::HttpEndpointRoleSettings, endpoint_index: usize, role_index: usize) -> ksp_core_lib::Result<()> { let role_validation = validate_descriptor(role.role().as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].role").as_str()); if let std::result::Result::Err(error) = role_validation { return std::result::Result::Err(error); } if role.request_kinds().is_empty() { return invalid_settings( "HTTP endpoint role must declare at least one request kind", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str(), ); } if role.request_kinds().len() > 1 && role.request_kinds().iter().any(crate::HttpRequestKind::is_wildcard) { return invalid_settings("wildcard request kind must be used alone", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds").as_str()); } for (request_kind_index, request_kind) in role.request_kinds().iter().enumerate() { let request_kind_validation = validate_descriptor(request_kind.as_str(), format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]").as_str()); if let std::result::Result::Err(error) = request_kind_validation { return std::result::Result::Err(error); } for previous in &role.request_kinds()[..request_kind_index] { if previous == request_kind { return std::result::Result::Err( ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, "HTTP request kinds must be unique per role") .with_context("field", format!("endpoints[{endpoint_index}].roles[{role_index}].request_kinds[{request_kind_index}]")) .with_context("request_kind", request_kind.as_str()), ); } } } if role.limits().burst_capacity().is_some() && role.limits().requests_per_second().is_none() { return invalid_settings( "burst capacity requires a requests-per-second limit", format!("endpoints[{endpoint_index}].roles[{role_index}].limits.burst_capacity").as_str(), ); } if let std::option::Option::Some(pause) = role.limits().pause_after_rate_limit() && pause.is_zero() { return invalid_settings( "rate-limit cooldown must be greater than zero when configured", format!("endpoints[{endpoint_index}].roles[{role_index}].limits.pause_after_rate_limit").as_str(), ); } return std::result::Result::Ok(()); } fn validate_descriptor(value: &str, field: &str) -> ksp_core_lib::Result<()> { if value.trim().is_empty() { return invalid_settings("transport descriptor must not be empty", field); } if value.trim() != value { return invalid_settings("transport descriptor must not contain leading or trailing whitespace", field); } return std::result::Result::Ok(()); } fn invalid_settings(message: &str, field: &str) -> ksp_core_lib::Result<()> { return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_SETTINGS, message).with_context("field", field)); } #[cfg(test)] #[path = "../unit_tests/settings.rs"] mod tests;