515 lines
20 KiB
Rust
515 lines
20 KiB
Rust
// file: crates/ksp-onchain-transport-lib/src/http_client.rs
|
|
// version: 8
|
|
|
|
/// Passive runtime availability reported for one logical HTTP endpoint or role.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum HttpEndpointAvailability {
|
|
/// The endpoint or role is administratively disabled and cannot be selected.
|
|
Disabled,
|
|
/// The endpoint or role is enabled and currently eligible for selection.
|
|
Available,
|
|
/// The endpoint or role is enabled but degraded by recent passive runtime observations.
|
|
Degraded,
|
|
/// The endpoint or role is temporarily excluded after provider rate limiting.
|
|
RateLimited,
|
|
}
|
|
|
|
/// Safe routing and resilience snapshot for one configured endpoint role.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct HttpEndpointRoleSnapshot {
|
|
role: std::string::String,
|
|
enabled: bool,
|
|
request_kinds: std::vec::Vec<std::string::String>,
|
|
priority: u32,
|
|
availability: crate::HttpEndpointAvailability,
|
|
requests_per_second: std::option::Option<u32>,
|
|
burst_capacity: std::option::Option<u32>,
|
|
max_concurrent_requests: std::option::Option<u32>,
|
|
in_flight_requests: std::option::Option<u32>,
|
|
cooldown_remaining: std::option::Option<std::time::Duration>,
|
|
success_count: u64,
|
|
failure_count: u64,
|
|
rate_limit_count: u64,
|
|
}
|
|
|
|
impl HttpEndpointRoleSnapshot {
|
|
/// Returns the logical role name.
|
|
#[must_use]
|
|
pub fn role(&self) -> &str {
|
|
return self.role.as_str();
|
|
}
|
|
|
|
/// Returns whether the role is enabled.
|
|
#[must_use]
|
|
pub const fn enabled(&self) -> bool {
|
|
return self.enabled;
|
|
}
|
|
|
|
/// Returns request-kind descriptors accepted by the role.
|
|
#[must_use]
|
|
pub fn request_kinds(&self) -> &[std::string::String] {
|
|
return self.request_kinds.as_slice();
|
|
}
|
|
|
|
/// Returns the routing priority where lower values are preferred.
|
|
#[must_use]
|
|
pub const fn priority(&self) -> u32 {
|
|
return self.priority;
|
|
}
|
|
|
|
/// Returns the passive runtime availability of this role.
|
|
#[must_use]
|
|
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
|
return self.availability;
|
|
}
|
|
|
|
/// Returns the configured requests-per-second limit.
|
|
#[must_use]
|
|
pub const fn requests_per_second(&self) -> std::option::Option<u32> {
|
|
return self.requests_per_second;
|
|
}
|
|
|
|
/// Returns the configured token-bucket burst capacity.
|
|
#[must_use]
|
|
pub const fn burst_capacity(&self) -> std::option::Option<u32> {
|
|
return self.burst_capacity;
|
|
}
|
|
|
|
/// Returns the configured maximum concurrent request count.
|
|
#[must_use]
|
|
pub const fn max_concurrent_requests(&self) -> std::option::Option<u32> {
|
|
return self.max_concurrent_requests;
|
|
}
|
|
|
|
/// Returns the number of in-flight requests when concurrency is bounded.
|
|
#[must_use]
|
|
pub const fn in_flight_requests(&self) -> std::option::Option<u32> {
|
|
return self.in_flight_requests;
|
|
}
|
|
|
|
/// Returns the remaining provider cooldown when this role is rate-limited.
|
|
#[must_use]
|
|
pub const fn cooldown_remaining(&self) -> std::option::Option<std::time::Duration> {
|
|
return self.cooldown_remaining;
|
|
}
|
|
|
|
/// Returns the number of successful requests passively recorded for this role.
|
|
#[must_use]
|
|
pub const fn success_count(&self) -> u64 {
|
|
return self.success_count;
|
|
}
|
|
|
|
/// Returns the number of failed requests passively recorded for this role.
|
|
#[must_use]
|
|
pub const fn failure_count(&self) -> u64 {
|
|
return self.failure_count;
|
|
}
|
|
|
|
/// Returns the number of provider rate-limit observations recorded for this role.
|
|
#[must_use]
|
|
pub const fn rate_limit_count(&self) -> u64 {
|
|
return self.rate_limit_count;
|
|
}
|
|
}
|
|
|
|
/// Safe metadata snapshot for one logical HTTP endpoint.
|
|
///
|
|
/// Endpoint URLs are intentionally absent because they can contain provider credentials.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct HttpEndpointSnapshot {
|
|
name: std::string::String,
|
|
provider: std::string::String,
|
|
cluster: std::string::String,
|
|
enabled: bool,
|
|
availability: crate::HttpEndpointAvailability,
|
|
roles: std::vec::Vec<crate::HttpEndpointRoleSnapshot>,
|
|
}
|
|
|
|
impl HttpEndpointSnapshot {
|
|
/// Returns the configured endpoint identity.
|
|
#[must_use]
|
|
pub fn name(&self) -> &str {
|
|
return self.name.as_str();
|
|
}
|
|
|
|
/// Returns the provider descriptor.
|
|
#[must_use]
|
|
pub fn provider(&self) -> &str {
|
|
return self.provider.as_str();
|
|
}
|
|
|
|
/// Returns the cluster descriptor.
|
|
#[must_use]
|
|
pub fn cluster(&self) -> &str {
|
|
return self.cluster.as_str();
|
|
}
|
|
|
|
/// Returns whether the endpoint is administratively enabled.
|
|
#[must_use]
|
|
pub const fn enabled(&self) -> bool {
|
|
return self.enabled;
|
|
}
|
|
|
|
/// Returns the passive runtime availability.
|
|
#[must_use]
|
|
pub const fn availability(&self) -> crate::HttpEndpointAvailability {
|
|
return self.availability;
|
|
}
|
|
|
|
/// Returns safe role snapshots in declaration order.
|
|
#[must_use]
|
|
pub fn roles(&self) -> &[crate::HttpEndpointRoleSnapshot] {
|
|
return self.roles.as_slice();
|
|
}
|
|
}
|
|
|
|
/// Shareable logical HTTP endpoint client owned by KSP Transport.
|
|
///
|
|
/// The underlying `reqwest::Client` owns socket pooling. KSP keeps the configured URL private from diagnostics and exposes only safe routing metadata.
|
|
#[derive(Clone)]
|
|
pub struct HttpEndpointClient {
|
|
inner: std::sync::Arc<HttpEndpointClientInner>,
|
|
}
|
|
|
|
impl HttpEndpointClient {
|
|
/// Builds one logical endpoint client from KSP-owned runtime settings.
|
|
pub fn new(settings: crate::HttpEndpointSettings) -> ksp_core_lib::Result<Self> {
|
|
return Self::new_with_notify(settings, std::sync::Arc::new(tokio::sync::Notify::new()));
|
|
}
|
|
|
|
/// Creates a new with notify value for `HttpEndpointClient`.
|
|
pub(crate) fn new_with_notify(settings: crate::HttpEndpointSettings, notify: std::sync::Arc<tokio::sync::Notify>) -> ksp_core_lib::Result<Self> {
|
|
let validation = crate::validate_endpoint_settings(&settings);
|
|
if let std::result::Result::Err(error) = validation {
|
|
return std::result::Result::Err(error);
|
|
}
|
|
let client_result = build_reqwest_client(&settings);
|
|
let client = match client_result {
|
|
std::result::Result::Ok(client) => client,
|
|
std::result::Result::Err(error) => {
|
|
return std::result::Result::Err(
|
|
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_CONNECTION_FAILED, "HTTP endpoint client could not be initialized")
|
|
.with_context("endpoint_name", settings.name())
|
|
.with_source(error.without_url()),
|
|
);
|
|
},
|
|
};
|
|
let mut role_runtimes = std::vec::Vec::with_capacity(settings.roles().len());
|
|
for role in settings.roles() {
|
|
role_runtimes.push(std::sync::Arc::new(crate::HttpRoleRuntime::new(role, std::sync::Arc::clone(¬ify))));
|
|
}
|
|
ksp_logging_lib::debug!(
|
|
target: crate::TRACING_TARGET,
|
|
endpoint_name = settings.name(),
|
|
provider = settings.provider().as_str(),
|
|
cluster = settings.cluster().as_str(),
|
|
enabled = settings.enabled(),
|
|
role_count = settings.roles().len(),
|
|
"created logical HTTP endpoint client"
|
|
);
|
|
return std::result::Result::Ok(Self { inner: std::sync::Arc::new(HttpEndpointClientInner { settings, client, role_runtimes }) });
|
|
}
|
|
|
|
/// Returns the endpoint identity used for safe diagnostics and routing.
|
|
#[must_use]
|
|
pub fn name(&self) -> &str {
|
|
return self.inner.settings.name();
|
|
}
|
|
|
|
/// Returns the provider descriptor.
|
|
#[must_use]
|
|
pub fn provider(&self) -> &crate::HttpProviderName {
|
|
return self.inner.settings.provider();
|
|
}
|
|
|
|
/// Returns the cluster descriptor.
|
|
#[must_use]
|
|
pub fn cluster(&self) -> &crate::HttpClusterName {
|
|
return self.inner.settings.cluster();
|
|
}
|
|
|
|
/// Returns whether the endpoint is administratively enabled.
|
|
#[must_use]
|
|
pub fn enabled(&self) -> bool {
|
|
return self.inner.settings.enabled();
|
|
}
|
|
|
|
/// Returns the configured end-to-end request timeout.
|
|
#[must_use]
|
|
pub fn request_timeout(&self) -> std::time::Duration {
|
|
return self.inner.settings.request_timeout();
|
|
}
|
|
|
|
/// Executes the crate-internal post json rpc operation for `HttpEndpointClient`.
|
|
pub(crate) async fn post_json_rpc(&self, payload: &str, timeout: std::time::Duration) -> ksp_core_lib::Result<HttpEndpointHttpResponse> {
|
|
if timeout.is_zero() {
|
|
return std::result::Result::Err(
|
|
ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, "HTTP JSON-RPC request budget expired before dispatch")
|
|
.with_context("endpoint_name", self.name()),
|
|
);
|
|
}
|
|
let send_result = self
|
|
.inner
|
|
.client
|
|
.post(self.inner.settings.url().as_str())
|
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
|
.body(payload.to_owned())
|
|
.timeout(timeout)
|
|
.send()
|
|
.await;
|
|
let response = match send_result {
|
|
std::result::Result::Ok(response) => response,
|
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
|
};
|
|
let status = response.status().as_u16();
|
|
let retry_after = parse_retry_after(response.headers());
|
|
let body_result = response.bytes().await;
|
|
let body = match body_result {
|
|
std::result::Result::Ok(body) => body.to_vec(),
|
|
std::result::Result::Err(error) => return std::result::Result::Err(map_reqwest_error(self.name(), error)),
|
|
};
|
|
return std::result::Result::Ok(HttpEndpointHttpResponse { status, retry_after, body });
|
|
}
|
|
|
|
/// Returns whether one enabled role can serve the requested capability structurally.
|
|
#[must_use]
|
|
pub fn supports(&self, role: &crate::HttpRoleName, request_kind: &crate::HttpRequestKind) -> bool {
|
|
return self.matching_role(role, request_kind).is_some();
|
|
}
|
|
|
|
/// Returns a safe endpoint snapshot with no URL or provider credential material.
|
|
#[must_use]
|
|
pub fn snapshot(&self) -> crate::HttpEndpointSnapshot {
|
|
let mut roles = std::vec::Vec::with_capacity(self.inner.settings.roles().len());
|
|
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
|
let mut request_kinds = std::vec::Vec::with_capacity(role.request_kinds().len());
|
|
for request_kind in role.request_kinds() {
|
|
request_kinds.push(request_kind.as_str().to_owned());
|
|
}
|
|
let runtime = self.inner.role_runtimes.get(role_index);
|
|
let role_snapshot = match runtime {
|
|
std::option::Option::Some(runtime) => role_snapshot(role, request_kinds, runtime),
|
|
std::option::Option::None => fallback_role_snapshot(role, request_kinds),
|
|
};
|
|
roles.push(role_snapshot);
|
|
}
|
|
return crate::HttpEndpointSnapshot {
|
|
name: self.name().to_owned(),
|
|
provider: self.provider().as_str().to_owned(),
|
|
cluster: self.cluster().as_str().to_owned(),
|
|
enabled: self.enabled(),
|
|
availability: self.availability(),
|
|
roles,
|
|
};
|
|
}
|
|
|
|
/// Executes the crate-internal matching role operation for `HttpEndpointClient`.
|
|
pub(crate) fn matching_role<'a>(
|
|
&'a self,
|
|
role: &crate::HttpRoleName,
|
|
request_kind: &crate::HttpRequestKind,
|
|
) -> std::option::Option<&'a crate::HttpEndpointRoleSettings> {
|
|
if !self.enabled() {
|
|
return std::option::Option::None;
|
|
}
|
|
for candidate_role in self.inner.settings.roles() {
|
|
if !candidate_role.enabled() || candidate_role.role() != role {
|
|
continue;
|
|
}
|
|
for capability in candidate_role.request_kinds() {
|
|
if capability.is_wildcard() || capability == request_kind {
|
|
return std::option::Option::Some(candidate_role);
|
|
}
|
|
}
|
|
}
|
|
return std::option::Option::None;
|
|
}
|
|
|
|
/// Executes the crate-internal matching role runtime operation for `HttpEndpointClient`.
|
|
pub(crate) fn matching_role_runtime(
|
|
&self,
|
|
role: &crate::HttpRoleName,
|
|
request_kind: &crate::HttpRequestKind,
|
|
) -> std::option::Option<(u32, std::sync::Arc<crate::HttpRoleRuntime>)> {
|
|
if !self.enabled() {
|
|
return std::option::Option::None;
|
|
}
|
|
for (role_index, candidate_role) in self.inner.settings.roles().iter().enumerate() {
|
|
if !candidate_role.enabled() || candidate_role.role() != role {
|
|
continue;
|
|
}
|
|
let mut handles = false;
|
|
for capability in candidate_role.request_kinds() {
|
|
if capability.is_wildcard() || capability == request_kind {
|
|
handles = true;
|
|
break;
|
|
}
|
|
}
|
|
if !handles {
|
|
continue;
|
|
}
|
|
let runtime = self.inner.role_runtimes.get(role_index);
|
|
if let std::option::Option::Some(runtime) = runtime {
|
|
return std::option::Option::Some((candidate_role.priority(), std::sync::Arc::clone(runtime)));
|
|
}
|
|
}
|
|
return std::option::Option::None;
|
|
}
|
|
|
|
/// Returns the current availability.
|
|
pub(crate) fn availability(&self) -> crate::HttpEndpointAvailability {
|
|
if !self.enabled() {
|
|
return crate::HttpEndpointAvailability::Disabled;
|
|
}
|
|
let now = std::time::Instant::now();
|
|
let mut enabled_role_count = 0_usize;
|
|
let mut rate_limited_count = 0_usize;
|
|
let mut degraded = false;
|
|
for (role_index, role) in self.inner.settings.roles().iter().enumerate() {
|
|
if !role.enabled() {
|
|
continue;
|
|
}
|
|
enabled_role_count = enabled_role_count.saturating_add(1);
|
|
let runtime = self.inner.role_runtimes.get(role_index);
|
|
let availability = match runtime {
|
|
std::option::Option::Some(runtime) => runtime.availability(now),
|
|
std::option::Option::None => crate::HttpEndpointAvailability::Degraded,
|
|
};
|
|
if availability == crate::HttpEndpointAvailability::RateLimited {
|
|
rate_limited_count = rate_limited_count.saturating_add(1);
|
|
}
|
|
if availability == crate::HttpEndpointAvailability::Degraded || availability == crate::HttpEndpointAvailability::RateLimited {
|
|
degraded = true;
|
|
}
|
|
}
|
|
if enabled_role_count > 0 && rate_limited_count == enabled_role_count {
|
|
return crate::HttpEndpointAvailability::RateLimited;
|
|
}
|
|
if degraded || enabled_role_count == 0 {
|
|
return crate::HttpEndpointAvailability::Degraded;
|
|
}
|
|
return crate::HttpEndpointAvailability::Available;
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for HttpEndpointClient {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
return formatter.debug_struct("HttpEndpointClient").field("snapshot", &self.snapshot()).finish();
|
|
}
|
|
}
|
|
|
|
/// Crate-internal `HttpEndpointHttpResponse` state shared across the owning crate.
|
|
pub(crate) struct HttpEndpointHttpResponse {
|
|
status: u16,
|
|
retry_after: std::option::Option<std::time::Duration>,
|
|
body: std::vec::Vec<u8>,
|
|
}
|
|
|
|
impl HttpEndpointHttpResponse {
|
|
/// Returns the current status.
|
|
pub(crate) const fn status(&self) -> u16 {
|
|
return self.status;
|
|
}
|
|
|
|
/// Returns the current retry after.
|
|
pub(crate) const fn retry_after(&self) -> std::option::Option<std::time::Duration> {
|
|
return self.retry_after;
|
|
}
|
|
|
|
/// Returns the current body.
|
|
pub(crate) fn body(&self) -> &[u8] {
|
|
return self.body.as_slice();
|
|
}
|
|
}
|
|
|
|
struct HttpEndpointClientInner {
|
|
settings: crate::HttpEndpointSettings,
|
|
client: reqwest::Client,
|
|
role_runtimes: std::vec::Vec<std::sync::Arc<crate::HttpRoleRuntime>>,
|
|
}
|
|
|
|
fn role_snapshot(
|
|
role: &crate::HttpEndpointRoleSettings,
|
|
request_kinds: std::vec::Vec<std::string::String>,
|
|
runtime: &crate::HttpRoleRuntime,
|
|
) -> crate::HttpEndpointRoleSnapshot {
|
|
let availability = if role.enabled() { runtime.availability(std::time::Instant::now()) } else { crate::HttpEndpointAvailability::Disabled };
|
|
return crate::HttpEndpointRoleSnapshot {
|
|
role: role.role().as_str().to_owned(),
|
|
enabled: role.enabled(),
|
|
request_kinds,
|
|
priority: role.priority(),
|
|
availability,
|
|
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
|
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
|
max_concurrent_requests: runtime.max_concurrent_requests(),
|
|
in_flight_requests: runtime.in_flight_requests(),
|
|
cooldown_remaining: runtime.cooldown_remaining(),
|
|
success_count: runtime.success_count(),
|
|
failure_count: runtime.failure_count(),
|
|
rate_limit_count: runtime.rate_limit_count(),
|
|
};
|
|
}
|
|
|
|
fn fallback_role_snapshot(role: &crate::HttpEndpointRoleSettings, request_kinds: std::vec::Vec<std::string::String>) -> crate::HttpEndpointRoleSnapshot {
|
|
return crate::HttpEndpointRoleSnapshot {
|
|
role: role.role().as_str().to_owned(),
|
|
enabled: role.enabled(),
|
|
request_kinds,
|
|
priority: role.priority(),
|
|
availability: crate::HttpEndpointAvailability::Degraded,
|
|
requests_per_second: role.limits().requests_per_second().map(|value| return value.get()),
|
|
burst_capacity: role.limits().burst_capacity().map(|value| return value.get()),
|
|
max_concurrent_requests: role.limits().max_concurrent_requests().map(|value| return value.get()),
|
|
in_flight_requests: std::option::Option::None,
|
|
cooldown_remaining: std::option::Option::None,
|
|
success_count: 0,
|
|
failure_count: 0,
|
|
rate_limit_count: 0,
|
|
};
|
|
}
|
|
|
|
fn map_reqwest_error(endpoint_name: &str, error: reqwest::Error) -> ksp_core_lib::Error {
|
|
let code = if error.is_timeout() {
|
|
crate::ERROR_CODE_TIMEOUT
|
|
} else if error.is_connect() {
|
|
crate::ERROR_CODE_HTTP_CONNECTION_FAILED
|
|
} else {
|
|
crate::ERROR_CODE_HTTP_REQUEST_FAILED
|
|
};
|
|
return ksp_core_lib::Error::new(code, "HTTP JSON-RPC request failed").with_context("endpoint_name", endpoint_name).with_source(error.without_url());
|
|
}
|
|
|
|
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> std::option::Option<std::time::Duration> {
|
|
let value = match headers.get(reqwest::header::RETRY_AFTER) {
|
|
std::option::Option::Some(value) => value,
|
|
std::option::Option::None => return std::option::Option::None,
|
|
};
|
|
let text = match value.to_str() {
|
|
std::result::Result::Ok(text) => text.trim(),
|
|
std::result::Result::Err(_) => return std::option::Option::None,
|
|
};
|
|
let seconds = match text.parse::<u64>() {
|
|
std::result::Result::Ok(seconds) => seconds,
|
|
std::result::Result::Err(_) => return std::option::Option::None,
|
|
};
|
|
return std::option::Option::Some(std::time::Duration::from_secs(seconds));
|
|
}
|
|
|
|
fn build_reqwest_client(settings: &crate::HttpEndpointSettings) -> std::result::Result<reqwest::Client, reqwest::Error> {
|
|
let mut builder = reqwest::Client::builder()
|
|
.connect_timeout(settings.connect_timeout())
|
|
.timeout(settings.request_timeout())
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.no_proxy()
|
|
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")));
|
|
if let std::option::Option::Some(max_idle) = settings.max_idle_connections_per_host() {
|
|
builder = builder.pool_max_idle_per_host(max_idle);
|
|
}
|
|
return builder.build();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/http_client.rs"]
|
|
mod tests;
|