Files
khadhroony-solana-project/crates/ksp-offchain-transport-lib/src/http_client.rs
2026-08-25 23:06:26 +02:00

365 lines
15 KiB
Rust

// file: crates/ksp-offchain-transport-lib/src/http_client.rs
// version: 4
//! 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<u8>,
}
impl crate::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 crate::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 crate::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<Self> {
return Self::parse(url, false);
}
/// Appends one validated non-secret path segment to a crate-owned official provider base URL.
pub(crate) fn append_path_segment(&mut self, value: &str) -> ksp_core_lib::Result<()> {
let segments_result = self.url.path_segments_mut();
let mut segments = match segments_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(()) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_HTTP_REQUEST_INVALID, "Off-chain provider URL cannot accept a path segment")
.with_context("field", "provider_url_path"),
);
},
};
segments.push(value);
return std::result::Result::Ok(());
}
/// 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(());
}
/// Reports whether a named header is present without exposing its value in tests.
#[cfg(test)]
pub(crate) fn has_header_for_test(&self, name: &'static str) -> bool {
return self.headers.contains_key(name);
}
/// 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<Self> {
return Self::parse(url, true);
}
/// Returns the constructed URL only to deterministic in-crate tests; production diagnostics remain redacted.
#[cfg(test)]
pub(crate) fn url_for_test(&self) -> &reqwest::Url {
return &self.url;
}
fn parse(url: &str, allow_http_for_tests: bool) -> ksp_core_lib::Result<Self> {
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 crate::HttpGetRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return formatter.write_str("HttpGetRequest(<redacted>)");
}
}
/// Shareable hardened REST client owned by Off-chain Transport.
#[derive(Clone)]
pub(crate) struct HttpRestClient {
client: reqwest::Client,
settings: crate::HttpClientSettings,
}
impl crate::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<Self> {
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: crate::HttpGetRequest,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
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::<serde::de::IgnoredAny>(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(crate::HttpJsonDocument { bytes: body });
}
}
fn classify_http_status(
provider: &'static str,
operation: &'static str,
status: u16,
retry_after: std::option::Option<std::time::Duration>,
) -> ksp_core_lib::Result<crate::HttpJsonDocument> {
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<std::time::Duration>,
) -> 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<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(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let seconds = match text.parse::<u64>() {
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<crate::HttpJsonDocument> {
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;