1608 lines
66 KiB
Rust
1608 lines
66 KiB
Rust
// file: kb_lib/src/http_client.rs
|
|
|
|
//! Generic asynchronous HTTP JSON-RPC client.
|
|
//!
|
|
//! This module provides a reusable `HttpClient` built on top of `reqwest` for
|
|
//! Solana RPC HTTP endpoints.
|
|
//!
|
|
//! Version `0.4.2` extends the `0.4.1` transport layer with:
|
|
//! - local endpoint status management (`Active`, `Paused`, `Disabled`)
|
|
//! - method classification (`GeneralRpc`, `SendTransaction`, `HeavyRead`)
|
|
//! - per-class local rate limiting
|
|
//! - automatic pause after HTTP 429 responses
|
|
|
|
/// JSON-RPC 2.0 request envelope for HTTP.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct JsonRpcHttpRequest {
|
|
/// JSON-RPC version, expected to be `"2.0"`.
|
|
pub jsonrpc: std::string::String,
|
|
/// Client request identifier.
|
|
pub id: serde_json::Value,
|
|
/// RPC method name.
|
|
pub method: std::string::String,
|
|
/// Ordered method parameters.
|
|
pub params: std::vec::Vec<serde_json::Value>,
|
|
}
|
|
|
|
impl JsonRpcHttpRequest {
|
|
/// Creates a new request with a numeric identifier.
|
|
pub fn new_with_u64_id(
|
|
id: u64,
|
|
method: std::string::String,
|
|
params: std::vec::Vec<serde_json::Value>,
|
|
) -> Self {
|
|
return Self {
|
|
jsonrpc: "2.0".to_string(),
|
|
id: serde_json::Value::from(id),
|
|
method,
|
|
params,
|
|
};
|
|
}
|
|
|
|
/// Serializes the request into a compact JSON string.
|
|
pub fn to_json_string(&self) -> Result<std::string::String, crate::Error> {
|
|
let text_result = serde_json::to_string(self);
|
|
match text_result {
|
|
Ok(text) => return Ok(text),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot serialize http json-rpc request '{}': {error}",
|
|
self.method
|
|
)));
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// JSON-RPC 2.0 success response.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct JsonRpcHttpSuccessResponse {
|
|
/// JSON-RPC version, expected to be `"2.0"`.
|
|
pub jsonrpc: std::string::String,
|
|
/// Result payload.
|
|
pub result: serde_json::Value,
|
|
/// Request identifier echoed by the server.
|
|
pub id: serde_json::Value,
|
|
}
|
|
|
|
/// JSON-RPC 2.0 error object.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct JsonRpcHttpErrorObject {
|
|
/// Numeric JSON-RPC error code.
|
|
pub code: i64,
|
|
/// Human-readable error message.
|
|
pub message: std::string::String,
|
|
/// Optional server-provided payload.
|
|
pub data: std::option::Option<serde_json::Value>,
|
|
}
|
|
|
|
/// JSON-RPC 2.0 error response.
|
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct JsonRpcHttpErrorResponse {
|
|
/// JSON-RPC version, expected to be `"2.0"`.
|
|
pub jsonrpc: std::string::String,
|
|
/// Error payload.
|
|
pub error: JsonRpcHttpErrorObject,
|
|
/// Request identifier echoed by the server.
|
|
pub id: serde_json::Value,
|
|
}
|
|
|
|
/// Parsed HTTP JSON-RPC response envelope.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum JsonRpcHttpResponse {
|
|
/// Success response.
|
|
Success(JsonRpcHttpSuccessResponse),
|
|
/// Error response.
|
|
Error(JsonRpcHttpErrorResponse),
|
|
}
|
|
|
|
/// Local HTTP method class used for independent limit buckets.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum HttpMethodClass {
|
|
/// Standard RPC reads and generic methods.
|
|
GeneralRpc,
|
|
/// Transaction submission methods.
|
|
SendTransaction,
|
|
/// Resource-intensive read methods.
|
|
HeavyRead,
|
|
}
|
|
|
|
/// Local endpoint status.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum HttpEndpointStatus {
|
|
/// Endpoint is ready to accept requests.
|
|
Active,
|
|
/// Endpoint is temporarily paused.
|
|
Paused {
|
|
/// Remaining pause duration in milliseconds.
|
|
remaining_ms: u64,
|
|
},
|
|
/// Endpoint is manually disabled.
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct HttpTokenBucket {
|
|
tokens: f64,
|
|
last_refill_at: std::time::Instant,
|
|
}
|
|
|
|
impl HttpTokenBucket {
|
|
fn new(burst_capacity: u32) -> HttpTokenBucket {
|
|
return HttpTokenBucket {
|
|
tokens: burst_capacity as f64,
|
|
last_refill_at: std::time::Instant::now(),
|
|
};
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum HttpEndpointLifecycleState {
|
|
Active,
|
|
PausedUntil(std::time::Instant),
|
|
Disabled,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct HttpRuntimeState {
|
|
lifecycle: HttpEndpointLifecycleState,
|
|
general_bucket: HttpTokenBucket,
|
|
send_transaction_bucket: HttpTokenBucket,
|
|
heavy_read_bucket: HttpTokenBucket,
|
|
}
|
|
|
|
impl HttpRuntimeState {
|
|
fn new(endpoint: &crate::HttpEndpointConfig) -> HttpRuntimeState {
|
|
return HttpRuntimeState {
|
|
lifecycle: HttpEndpointLifecycleState::Active,
|
|
general_bucket: HttpTokenBucket::new(endpoint.burst_capacity),
|
|
send_transaction_bucket: HttpTokenBucket::new(http_effective_burst_capacity(
|
|
endpoint,
|
|
HttpMethodClass::SendTransaction,
|
|
)),
|
|
heavy_read_bucket: HttpTokenBucket::new(http_effective_burst_capacity(
|
|
endpoint,
|
|
HttpMethodClass::HeavyRead,
|
|
)),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Generic asynchronous HTTP client.
|
|
#[derive(Clone, Debug)]
|
|
pub struct HttpClient {
|
|
endpoint: crate::HttpEndpointConfig,
|
|
resolved_url: std::string::String,
|
|
client: reqwest::Client,
|
|
next_request_id: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
runtime: std::sync::Arc<tokio::sync::Mutex<HttpRuntimeState>>,
|
|
concurrency_limiter: std::sync::Arc<tokio::sync::Semaphore>,
|
|
}
|
|
|
|
impl HttpClient {
|
|
/// Creates a new HTTP client bound to one endpoint configuration.
|
|
pub fn new(endpoint: crate::HttpEndpointConfig) -> Result<Self, crate::Error> {
|
|
if endpoint.name.trim().is_empty() {
|
|
return Err(crate::Error::Config(
|
|
"http client endpoint name must not be empty".to_string(),
|
|
));
|
|
}
|
|
if endpoint.requests_per_second == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have requests_per_second > 0",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
if endpoint.burst_capacity == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have burst_capacity > 0",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
if let Some(send_transaction_requests_per_second) =
|
|
endpoint.send_transaction_requests_per_second
|
|
{
|
|
if send_transaction_requests_per_second == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have send_transaction_requests_per_second > 0 when configured",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
}
|
|
if let Some(send_transaction_burst_capacity) = endpoint.send_transaction_burst_capacity {
|
|
if send_transaction_burst_capacity == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have send_transaction_burst_capacity > 0 when configured",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
}
|
|
if let Some(heavy_requests_per_second) = endpoint.heavy_requests_per_second {
|
|
if heavy_requests_per_second == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have heavy_requests_per_second > 0 when configured",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
}
|
|
if let Some(heavy_burst_capacity) = endpoint.heavy_burst_capacity {
|
|
if heavy_burst_capacity == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have heavy_burst_capacity > 0 when configured",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
}
|
|
if endpoint.max_idle_connections_per_host == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have max_idle_connections_per_host > 0",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
if endpoint.max_concurrent_requests_per_endpoint == 0 {
|
|
return Err(crate::Error::Config(format!(
|
|
"http endpoint '{}' must have max_concurrent_requests_per_endpoint > 0",
|
|
endpoint.name
|
|
)));
|
|
}
|
|
let resolved_url_result = endpoint.resolved_url();
|
|
let resolved_url = match resolved_url_result {
|
|
Ok(resolved_url) => resolved_url,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let builder = reqwest::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_millis(endpoint.connect_timeout_ms))
|
|
.timeout(std::time::Duration::from_millis(endpoint.request_timeout_ms))
|
|
.pool_max_idle_per_host(endpoint.max_idle_connections_per_host)
|
|
.user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")));
|
|
let client_result = builder.build();
|
|
let client = match client_result {
|
|
Ok(client) => client,
|
|
Err(error) => {
|
|
return Err(crate::Error::Http(format!(
|
|
"cannot build reqwest client for endpoint '{}': {error}",
|
|
endpoint.name
|
|
)));
|
|
},
|
|
};
|
|
return Ok(Self {
|
|
endpoint: endpoint.clone(),
|
|
resolved_url,
|
|
client,
|
|
next_request_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
|
runtime: std::sync::Arc::new(tokio::sync::Mutex::new(HttpRuntimeState::new(&endpoint))),
|
|
concurrency_limiter: std::sync::Arc::new(tokio::sync::Semaphore::new(
|
|
endpoint.max_concurrent_requests_per_endpoint,
|
|
)),
|
|
});
|
|
}
|
|
|
|
/// Returns the endpoint name.
|
|
pub fn endpoint_name(&self) -> &str {
|
|
return &self.endpoint.name;
|
|
}
|
|
|
|
/// Returns the resolved endpoint URL.
|
|
pub fn endpoint_url(&self) -> &str {
|
|
return &self.resolved_url;
|
|
}
|
|
|
|
/// Returns the endpoint configuration.
|
|
pub fn endpoint_config(&self) -> &crate::HttpEndpointConfig {
|
|
return &self.endpoint;
|
|
}
|
|
|
|
/// Returns whether this endpoint supports the requested logical role.
|
|
pub fn supports_role(&self, required_role: &str) -> bool {
|
|
if required_role.trim().is_empty() {
|
|
return true;
|
|
}
|
|
return self.endpoint.roles.iter().any(|role| return role == required_role);
|
|
}
|
|
|
|
/// Returns the currently available concurrency slots for this endpoint.
|
|
pub fn available_concurrency_slots(&self) -> usize {
|
|
return self.concurrency_limiter.available_permits();
|
|
}
|
|
|
|
/// Returns the next request identifier and increments the internal counter.
|
|
pub fn next_request_id(&self) -> u64 {
|
|
return self.next_request_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
/// Returns the current local endpoint status.
|
|
pub async fn endpoint_status(&self) -> HttpEndpointStatus {
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
http_normalize_runtime_lifecycle(&mut runtime_guard);
|
|
match runtime_guard.lifecycle.clone() {
|
|
HttpEndpointLifecycleState::Active => return HttpEndpointStatus::Active,
|
|
HttpEndpointLifecycleState::Disabled => return HttpEndpointStatus::Disabled,
|
|
HttpEndpointLifecycleState::PausedUntil(deadline) => {
|
|
let now = std::time::Instant::now();
|
|
if deadline <= now {
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::Active;
|
|
return HttpEndpointStatus::Active;
|
|
} else {
|
|
let remaining = deadline.duration_since(now);
|
|
let remaining_ms_u128 = remaining.as_millis();
|
|
let remaining_ms = if remaining_ms_u128 > u128::from(u64::MAX) {
|
|
u64::MAX
|
|
} else {
|
|
remaining_ms_u128 as u64
|
|
};
|
|
return HttpEndpointStatus::Paused { remaining_ms };
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Pauses this endpoint locally before future sends.
|
|
pub async fn pause_for(&self, duration_ms: u64) {
|
|
let pause_duration = std::time::Duration::from_millis(duration_ms);
|
|
let pause_deadline = std::time::Instant::now() + pause_duration;
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::PausedUntil(pause_deadline);
|
|
}
|
|
|
|
/// Resumes this endpoint if it is paused.
|
|
pub async fn resume(&self) {
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
if matches!(runtime_guard.lifecycle, HttpEndpointLifecycleState::PausedUntil(_)) {
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::Active;
|
|
}
|
|
}
|
|
|
|
/// Disables this endpoint locally.
|
|
pub async fn disable(&self) {
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::Disabled;
|
|
}
|
|
|
|
/// Re-enables this endpoint locally.
|
|
pub async fn enable(&self) {
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::Active;
|
|
}
|
|
|
|
/// Classifies one HTTP JSON-RPC method.
|
|
pub fn classify_method(method: &str) -> HttpMethodClass {
|
|
return http_classify_method(method);
|
|
}
|
|
|
|
/// Executes one JSON-RPC request and returns the success envelope.
|
|
pub async fn execute_json_rpc_request_raw(
|
|
&self,
|
|
method: std::string::String,
|
|
params: std::vec::Vec<serde_json::Value>,
|
|
) -> Result<JsonRpcHttpSuccessResponse, crate::Error> {
|
|
let request_id = self.next_request_id();
|
|
let request = JsonRpcHttpRequest::new_with_u64_id(request_id, method, params);
|
|
return self.execute_json_rpc_request_object(&request).await;
|
|
}
|
|
|
|
/// Executes one prebuilt JSON-RPC request object.
|
|
pub async fn execute_json_rpc_request_object(
|
|
&self,
|
|
request: &JsonRpcHttpRequest,
|
|
) -> Result<JsonRpcHttpSuccessResponse, crate::Error> {
|
|
let method_class = http_classify_method(&request.method);
|
|
let rate_limit_result = self.acquire_rate_limit_slot_for_method_class(method_class).await;
|
|
if let Err(error) = rate_limit_result {
|
|
return Err(error);
|
|
}
|
|
let body_result = request.to_json_string();
|
|
let body = match body_result {
|
|
Ok(body) => body,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let concurrency_permit_result = self.concurrency_limiter.clone().acquire_owned().await;
|
|
let _concurrency_permit = match concurrency_permit_result {
|
|
Ok(concurrency_permit) => concurrency_permit,
|
|
Err(error) => {
|
|
return Err(crate::Error::Http(format!(
|
|
"cannot acquire concurrency slot for endpoint '{}' method '{}': {}",
|
|
self.endpoint.name, request.method, error
|
|
)));
|
|
},
|
|
};
|
|
tracing::trace!(
|
|
endpoint_name = %self.endpoint.name,
|
|
endpoint_url = %self.resolved_url,
|
|
method = %request.method,
|
|
method_class = ?method_class,
|
|
request_id = %request.id,
|
|
"sending http json-rpc request"
|
|
);
|
|
let send_result = self
|
|
.client
|
|
.post(self.resolved_url.clone())
|
|
.header("content-type", "application/json")
|
|
.body(body)
|
|
.send()
|
|
.await;
|
|
let response = match send_result {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
return Err(crate::Error::Http(format!(
|
|
"http request failed for endpoint '{}' method '{}': {error}",
|
|
self.endpoint.name, request.method
|
|
)));
|
|
},
|
|
};
|
|
let status = response.status();
|
|
let retry_after_header = response.headers().get(reqwest::header::RETRY_AFTER).cloned();
|
|
let text_result = response.text().await;
|
|
let text = match text_result {
|
|
Ok(text) => text,
|
|
Err(error) => {
|
|
return Err(crate::Error::Http(format!(
|
|
"cannot read http response body for endpoint '{}' method '{}': {error}",
|
|
self.endpoint.name, request.method
|
|
)));
|
|
},
|
|
};
|
|
if status.as_u16() == 429 {
|
|
let pause_duration_ms =
|
|
match retry_after_header.as_ref().and_then(http_retry_after_to_pause_ms) {
|
|
Some(retry_after_ms) => retry_after_ms,
|
|
None => self.endpoint.pause_after_http_429_ms.unwrap_or(1500),
|
|
};
|
|
self.pause_for(pause_duration_ms).await;
|
|
return Err(crate::Error::Http(format!(
|
|
"http status 429 returned by endpoint '{}' method '{}'; endpoint paused for {} ms; body='{}'",
|
|
self.endpoint.name,
|
|
request.method,
|
|
pause_duration_ms,
|
|
http_shorten_text(&text, 512)
|
|
)));
|
|
}
|
|
if !status.is_success() {
|
|
return Err(crate::Error::Http(format!(
|
|
"http status {} returned by endpoint '{}' method '{}' body='{}'",
|
|
status,
|
|
self.endpoint.name,
|
|
request.method,
|
|
http_shorten_text(&text, 512)
|
|
)));
|
|
}
|
|
let parse_result = parse_json_rpc_http_response_text(&text);
|
|
let parsed_response = match parse_result {
|
|
Ok(parsed_response) => parsed_response,
|
|
Err(error) => return Err(error),
|
|
};
|
|
match parsed_response {
|
|
JsonRpcHttpResponse::Success(success_response) => return Ok(success_response),
|
|
JsonRpcHttpResponse::Error(error_response) => {
|
|
return Err(crate::Error::Http(format!(
|
|
"json-rpc http error on endpoint '{}' method '{}': code={} message={}",
|
|
self.endpoint.name,
|
|
request.method,
|
|
error_response.error.code,
|
|
error_response.error.message
|
|
)));
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Executes one JSON-RPC request and returns only the raw `result` field.
|
|
pub async fn execute_json_rpc_result_raw(
|
|
&self,
|
|
method: std::string::String,
|
|
params: std::vec::Vec<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let raw_result = self.execute_json_rpc_request_raw(method, params).await;
|
|
let raw_response = match raw_result {
|
|
Ok(raw_response) => raw_response,
|
|
Err(error) => return Err(error),
|
|
};
|
|
return Ok(raw_response.result);
|
|
}
|
|
|
|
/// Executes one JSON-RPC request and decodes `result` into `T`.
|
|
pub async fn execute_json_rpc_request_typed<T>(
|
|
&self,
|
|
method: std::string::String,
|
|
params: std::vec::Vec<serde_json::Value>,
|
|
) -> Result<T, crate::Error>
|
|
where
|
|
T: serde::de::DeserializeOwned,
|
|
{
|
|
let raw_result = self.execute_json_rpc_result_raw(method, params).await;
|
|
let raw_value = match raw_result {
|
|
Ok(raw_value) => raw_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let typed_result = serde_json::from_value::<T>(raw_value);
|
|
match typed_result {
|
|
Ok(value) => return Ok(value),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot decode typed http json-rpc result: {error}"
|
|
)));
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Raw helper for `getHealth`.
|
|
pub async fn get_health_raw(&self) -> Result<serde_json::Value, crate::Error> {
|
|
return self
|
|
.execute_json_rpc_result_raw("getHealth".to_string(), std::vec::Vec::new())
|
|
.await;
|
|
}
|
|
|
|
/// Typed helper for `getHealth`.
|
|
pub async fn get_health(&self) -> Result<std::string::String, crate::Error> {
|
|
return self
|
|
.execute_json_rpc_request_typed::<std::string::String>(
|
|
"getHealth".to_string(),
|
|
std::vec::Vec::new(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getVersion`.
|
|
pub async fn get_version_raw(&self) -> Result<serde_json::Value, crate::Error> {
|
|
return self
|
|
.execute_json_rpc_result_raw("getVersion".to_string(), std::vec::Vec::new())
|
|
.await;
|
|
}
|
|
|
|
/// Typed helper for `getVersion`.
|
|
pub async fn get_version(
|
|
&self,
|
|
) -> Result<solana_rpc_client_api::response::RpcVersionInfo, crate::Error> {
|
|
return self
|
|
.execute_json_rpc_request_typed::<solana_rpc_client_api::response::RpcVersionInfo>(
|
|
"getVersion".to_string(),
|
|
std::vec::Vec::new(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getSlot`.
|
|
pub async fn get_slot_raw(
|
|
&self,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_optional_config_only_params(config);
|
|
return self.execute_json_rpc_result_raw("getSlot".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getSlot`.
|
|
pub async fn get_slot(
|
|
&self,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
|
|
) -> Result<u64, crate::Error> {
|
|
let config_value_result = serialize_optional_json_value(config, "getSlot config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_optional_config_only_params(config_value);
|
|
return self.execute_json_rpc_request_typed::<u64>("getSlot".to_string(), params).await;
|
|
}
|
|
|
|
/// Raw helper for `getBlockHeight`.
|
|
pub async fn get_block_height_raw(
|
|
&self,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_optional_config_only_params(config);
|
|
return self.execute_json_rpc_result_raw("getBlockHeight".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getBlockHeight`.
|
|
pub async fn get_block_height(
|
|
&self,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
|
|
) -> Result<u64, crate::Error> {
|
|
let config_value_result = serialize_optional_json_value(config, "getBlockHeight config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_optional_config_only_params(config_value);
|
|
return self
|
|
.execute_json_rpc_request_typed::<u64>("getBlockHeight".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getLatestBlockhash`.
|
|
pub async fn get_latest_blockhash_raw(
|
|
&self,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_optional_config_only_params(config);
|
|
return self.execute_json_rpc_result_raw("getLatestBlockhash".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getLatestBlockhash`.
|
|
pub async fn get_latest_blockhash(
|
|
&self,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
|
|
) -> Result<
|
|
solana_rpc_client_api::response::Response<solana_rpc_client_api::response::RpcBlockhash>,
|
|
crate::Error,
|
|
> {
|
|
let config_value_result =
|
|
serialize_optional_json_value(config, "getLatestBlockhash config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_optional_config_only_params(config_value);
|
|
return self.execute_json_rpc_request_typed::<
|
|
solana_rpc_client_api::response::Response<
|
|
solana_rpc_client_api::response::RpcBlockhash,
|
|
>,
|
|
>("getLatestBlockhash".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getBalance`.
|
|
pub async fn get_balance_raw(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(address, config);
|
|
return self.execute_json_rpc_result_raw("getBalance".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getBalance`.
|
|
pub async fn get_balance(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcContextConfig>,
|
|
) -> Result<solana_rpc_client_api::response::Response<u64>, crate::Error> {
|
|
let config_value_result = serialize_optional_json_value(config, "getBalance config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_first_string_optional_config_params(address, config_value);
|
|
return self
|
|
.execute_json_rpc_request_typed::<solana_rpc_client_api::response::Response<u64>>(
|
|
"getBalance".to_string(),
|
|
params,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getAccountInfo`.
|
|
pub async fn get_account_info_raw(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(address, config);
|
|
return self.execute_json_rpc_result_raw("getAccountInfo".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getAccountInfo`.
|
|
pub async fn get_account_info(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcAccountInfoConfig>,
|
|
) -> Result<
|
|
solana_rpc_client_api::response::Response<
|
|
std::option::Option<solana_rpc_client_api::response::UiAccount>,
|
|
>,
|
|
crate::Error,
|
|
> {
|
|
let config_value_result = serialize_optional_json_value(config, "getAccountInfo config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_first_string_optional_config_params(address, config_value);
|
|
return self
|
|
.execute_json_rpc_request_typed::<solana_rpc_client_api::response::Response<
|
|
std::option::Option<solana_rpc_client_api::response::UiAccount>,
|
|
>>("getAccountInfo".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getProgramAccounts`.
|
|
pub async fn get_program_accounts_raw(
|
|
&self,
|
|
program_id: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(program_id, config);
|
|
return self.execute_json_rpc_result_raw("getProgramAccounts".to_string(), params).await;
|
|
}
|
|
|
|
/// Raw helper for `getSignaturesForAddress`.
|
|
pub async fn get_signatures_for_address_raw(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(address, config);
|
|
return self
|
|
.execute_json_rpc_result_raw("getSignaturesForAddress".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Typed helper for `getSignaturesForAddress`.
|
|
pub async fn get_signatures_for_address(
|
|
&self,
|
|
address: std::string::String,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcSignaturesForAddressConfig>,
|
|
) -> Result<
|
|
std::vec::Vec<solana_rpc_client_api::response::RpcConfirmedTransactionStatusWithSignature>,
|
|
crate::Error,
|
|
> {
|
|
let config_value_result =
|
|
serialize_optional_json_value(config, "getSignaturesForAddress config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_first_string_optional_config_params(address, config_value);
|
|
return self
|
|
.execute_json_rpc_request_typed::<std::vec::Vec<
|
|
solana_rpc_client_api::response::RpcConfirmedTransactionStatusWithSignature,
|
|
>>("getSignaturesForAddress".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `getTransaction`.
|
|
pub async fn get_transaction_raw(
|
|
&self,
|
|
signature: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(signature, config);
|
|
return self.execute_json_rpc_result_raw("getTransaction".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `getTransaction`.
|
|
pub async fn get_transaction(
|
|
&self,
|
|
signature: std::string::String,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcTransactionConfig>,
|
|
) -> Result<
|
|
std::option::Option<solana_rpc_client_api::response::EncodedTransactionWithStatusMeta>,
|
|
crate::Error,
|
|
> {
|
|
let config_value_result = serialize_optional_json_value(config, "getTransaction config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_first_string_optional_config_params(signature, config_value);
|
|
return self.execute_json_rpc_request_typed::<
|
|
std::option::Option<solana_rpc_client_api::response::EncodedTransactionWithStatusMeta>,
|
|
>("getTransaction".to_string(), params)
|
|
.await;
|
|
}
|
|
|
|
/// Raw helper for `sendTransaction`.
|
|
pub async fn send_transaction_raw(
|
|
&self,
|
|
encoded_transaction: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value, crate::Error> {
|
|
let params = build_first_string_optional_config_params(encoded_transaction, config);
|
|
return self.execute_json_rpc_result_raw("sendTransaction".to_string(), params).await;
|
|
}
|
|
|
|
/// Typed helper for `sendTransaction`.
|
|
pub async fn send_transaction(
|
|
&self,
|
|
encoded_transaction: std::string::String,
|
|
config: std::option::Option<solana_rpc_client_api::config::RpcSendTransactionConfig>,
|
|
) -> Result<std::string::String, crate::Error> {
|
|
let config_value_result = serialize_optional_json_value(config, "sendTransaction config");
|
|
let config_value = match config_value_result {
|
|
Ok(config_value) => config_value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let params = build_first_string_optional_config_params(encoded_transaction, config_value);
|
|
return self
|
|
.execute_json_rpc_request_typed::<std::string::String>(
|
|
"sendTransaction".to_string(),
|
|
params,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn acquire_rate_limit_slot_for_method_class(
|
|
&self,
|
|
method_class: HttpMethodClass,
|
|
) -> Result<(), crate::Error> {
|
|
loop {
|
|
let wait_duration_option = {
|
|
let mut runtime_guard = self.runtime.lock().await;
|
|
http_normalize_runtime_lifecycle(&mut runtime_guard);
|
|
match runtime_guard.lifecycle.clone() {
|
|
HttpEndpointLifecycleState::Disabled => {
|
|
return Err(crate::Error::Http(format!(
|
|
"http endpoint '{}' is disabled",
|
|
self.endpoint.name
|
|
)));
|
|
},
|
|
HttpEndpointLifecycleState::PausedUntil(deadline) => {
|
|
let now = std::time::Instant::now();
|
|
if deadline > now {
|
|
Some(deadline.duration_since(now))
|
|
} else {
|
|
runtime_guard.lifecycle = HttpEndpointLifecycleState::Active;
|
|
http_consume_rate_limit_token(
|
|
&self.endpoint,
|
|
&mut runtime_guard,
|
|
method_class,
|
|
)
|
|
}
|
|
},
|
|
HttpEndpointLifecycleState::Active => http_consume_rate_limit_token(
|
|
&self.endpoint,
|
|
&mut runtime_guard,
|
|
method_class,
|
|
),
|
|
}
|
|
};
|
|
match wait_duration_option {
|
|
Some(wait_duration) => {
|
|
tokio::time::sleep(wait_duration).await;
|
|
},
|
|
None => {
|
|
break;
|
|
},
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
/// Parses one JSON-RPC HTTP response text.
|
|
pub fn parse_json_rpc_http_response_text(text: &str) -> Result<JsonRpcHttpResponse, crate::Error> {
|
|
let value_result = serde_json::from_str::<serde_json::Value>(text);
|
|
let value = match value_result {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!("cannot parse http json-rpc text: {error}")));
|
|
},
|
|
};
|
|
return parse_json_rpc_http_response_value(&value);
|
|
}
|
|
|
|
/// Parses one JSON-RPC HTTP response value.
|
|
pub fn parse_json_rpc_http_response_value(
|
|
value: &serde_json::Value,
|
|
) -> Result<JsonRpcHttpResponse, crate::Error> {
|
|
let object_option = value.as_object();
|
|
let object = match object_option {
|
|
Some(object) => object,
|
|
None => {
|
|
return Err(crate::Error::Json(
|
|
"http json-rpc payload must be a JSON object".to_string(),
|
|
));
|
|
},
|
|
};
|
|
let jsonrpc_value_option = object.get("jsonrpc");
|
|
let jsonrpc_value = match jsonrpc_value_option {
|
|
Some(jsonrpc_value) => jsonrpc_value,
|
|
None => {
|
|
return Err(crate::Error::Json(
|
|
"http json-rpc payload is missing 'jsonrpc'".to_string(),
|
|
));
|
|
},
|
|
};
|
|
let jsonrpc_string_option = jsonrpc_value.as_str();
|
|
let jsonrpc_string = match jsonrpc_string_option {
|
|
Some(jsonrpc_string) => jsonrpc_string,
|
|
None => {
|
|
return Err(crate::Error::Json(
|
|
"http json-rpc field 'jsonrpc' must be a string".to_string(),
|
|
));
|
|
},
|
|
};
|
|
if jsonrpc_string != "2.0" {
|
|
return Err(crate::Error::Json(format!(
|
|
"unsupported http json-rpc version '{}'",
|
|
jsonrpc_string
|
|
)));
|
|
}
|
|
let has_result = object.contains_key("result");
|
|
let has_error = object.contains_key("error");
|
|
let has_id = object.contains_key("id");
|
|
if has_id && has_result && !has_error {
|
|
let response_result = serde_json::from_value::<JsonRpcHttpSuccessResponse>(value.clone());
|
|
return match response_result {
|
|
Ok(response) => Ok(JsonRpcHttpResponse::Success(response)),
|
|
Err(error) => Err(crate::Error::Json(format!(
|
|
"cannot parse http json-rpc success response: {error}"
|
|
))),
|
|
};
|
|
}
|
|
if has_id && has_error && !has_result {
|
|
let response_result = serde_json::from_value::<JsonRpcHttpErrorResponse>(value.clone());
|
|
return match response_result {
|
|
Ok(response) => Ok(JsonRpcHttpResponse::Error(response)),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!(
|
|
"cannot parse http json-rpc error response: {error}"
|
|
)));
|
|
},
|
|
};
|
|
}
|
|
return Err(crate::Error::Json("unsupported http json-rpc response shape".to_string()));
|
|
}
|
|
|
|
fn build_optional_config_only_params(
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> std::vec::Vec<serde_json::Value> {
|
|
let mut params = std::vec::Vec::new();
|
|
if let Some(config) = config {
|
|
params.push(config);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
fn build_first_string_optional_config_params(
|
|
first: std::string::String,
|
|
config: std::option::Option<serde_json::Value>,
|
|
) -> std::vec::Vec<serde_json::Value> {
|
|
let mut params = vec![serde_json::Value::String(first)];
|
|
if let Some(config) = config {
|
|
params.push(config);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
fn serialize_optional_json_value<T>(
|
|
value: std::option::Option<T>,
|
|
label: &str,
|
|
) -> Result<std::option::Option<serde_json::Value>, crate::Error>
|
|
where
|
|
T: serde::Serialize,
|
|
{
|
|
match value {
|
|
Some(value) => {
|
|
let value_result = serde_json::to_value(value);
|
|
match value_result {
|
|
Ok(value) => return Ok(Some(value)),
|
|
Err(error) => {
|
|
return Err(crate::Error::Json(format!("cannot serialize {}: {error}", label)));
|
|
},
|
|
}
|
|
},
|
|
None => return Ok(None),
|
|
}
|
|
}
|
|
|
|
fn http_classify_method(method: &str) -> HttpMethodClass {
|
|
if method == "sendTransaction" || method == "sendRawTransaction" {
|
|
return HttpMethodClass::SendTransaction;
|
|
}
|
|
if method == "getProgramAccounts" || method == "getLargestAccounts" {
|
|
return HttpMethodClass::HeavyRead;
|
|
}
|
|
return HttpMethodClass::GeneralRpc;
|
|
}
|
|
|
|
fn http_effective_requests_per_second(
|
|
endpoint: &crate::HttpEndpointConfig,
|
|
method_class: HttpMethodClass,
|
|
) -> u32 {
|
|
match method_class {
|
|
HttpMethodClass::GeneralRpc => return endpoint.requests_per_second,
|
|
HttpMethodClass::SendTransaction => {
|
|
return endpoint
|
|
.send_transaction_requests_per_second
|
|
.unwrap_or(endpoint.requests_per_second);
|
|
},
|
|
HttpMethodClass::HeavyRead => {
|
|
return endpoint.heavy_requests_per_second.unwrap_or(endpoint.requests_per_second);
|
|
},
|
|
}
|
|
}
|
|
|
|
fn http_effective_burst_capacity(
|
|
endpoint: &crate::HttpEndpointConfig,
|
|
method_class: HttpMethodClass,
|
|
) -> u32 {
|
|
match method_class {
|
|
HttpMethodClass::GeneralRpc => return endpoint.burst_capacity,
|
|
HttpMethodClass::SendTransaction => {
|
|
return endpoint.send_transaction_burst_capacity.unwrap_or(endpoint.burst_capacity);
|
|
},
|
|
HttpMethodClass::HeavyRead => {
|
|
return endpoint.heavy_burst_capacity.unwrap_or(endpoint.burst_capacity);
|
|
},
|
|
}
|
|
}
|
|
|
|
fn http_normalize_runtime_lifecycle(runtime: &mut HttpRuntimeState) {
|
|
if let HttpEndpointLifecycleState::PausedUntil(deadline) = runtime.lifecycle.clone() {
|
|
if deadline <= std::time::Instant::now() {
|
|
runtime.lifecycle = HttpEndpointLifecycleState::Active;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn http_consume_rate_limit_token(
|
|
endpoint: &crate::HttpEndpointConfig,
|
|
runtime: &mut HttpRuntimeState,
|
|
method_class: HttpMethodClass,
|
|
) -> std::option::Option<std::time::Duration> {
|
|
let (bucket, requests_per_second, burst_capacity) = match method_class {
|
|
HttpMethodClass::GeneralRpc => (
|
|
&mut runtime.general_bucket,
|
|
http_effective_requests_per_second(endpoint, method_class),
|
|
http_effective_burst_capacity(endpoint, method_class),
|
|
),
|
|
HttpMethodClass::SendTransaction => (
|
|
&mut runtime.send_transaction_bucket,
|
|
http_effective_requests_per_second(endpoint, method_class),
|
|
http_effective_burst_capacity(endpoint, method_class),
|
|
),
|
|
HttpMethodClass::HeavyRead => (
|
|
&mut runtime.heavy_read_bucket,
|
|
http_effective_requests_per_second(endpoint, method_class),
|
|
http_effective_burst_capacity(endpoint, method_class),
|
|
),
|
|
};
|
|
let now = std::time::Instant::now();
|
|
let elapsed_seconds = now.duration_since(bucket.last_refill_at).as_secs_f64();
|
|
let replenished_tokens = bucket.tokens + elapsed_seconds * requests_per_second as f64;
|
|
bucket.tokens = replenished_tokens.min(burst_capacity as f64);
|
|
bucket.last_refill_at = now;
|
|
if bucket.tokens >= 1.0 {
|
|
bucket.tokens -= 1.0;
|
|
return None;
|
|
}
|
|
let missing_tokens = 1.0 - bucket.tokens;
|
|
let wait_seconds = missing_tokens / requests_per_second as f64;
|
|
return Some(std::time::Duration::from_secs_f64(wait_seconds.max(0.001)));
|
|
}
|
|
|
|
fn http_retry_after_to_pause_ms(
|
|
header_value: &reqwest::header::HeaderValue,
|
|
) -> std::option::Option<u64> {
|
|
let header_text_result = header_value.to_str();
|
|
let header_text = match header_text_result {
|
|
Ok(header_text) => header_text.trim(),
|
|
Err(_) => {
|
|
return None;
|
|
},
|
|
};
|
|
let seconds_result = header_text.parse::<u64>();
|
|
if let Ok(seconds) = seconds_result {
|
|
return Some(seconds.saturating_mul(1000));
|
|
}
|
|
let parsed_date_result = chrono::DateTime::parse_from_rfc2822(header_text);
|
|
let parsed_date = match parsed_date_result {
|
|
Ok(parsed_date) => parsed_date.with_timezone(&chrono::Utc),
|
|
Err(_) => {
|
|
return None;
|
|
},
|
|
};
|
|
let now = chrono::Utc::now();
|
|
let delta_ms = parsed_date.signed_duration_since(now).num_milliseconds();
|
|
if delta_ms <= 0 {
|
|
return Some(0);
|
|
}
|
|
return Some(delta_ms as u64);
|
|
}
|
|
|
|
fn http_shorten_text(input: &str, max_chars: usize) -> std::string::String {
|
|
let char_count = input.chars().count();
|
|
if char_count <= max_chars {
|
|
return input.to_string();
|
|
}
|
|
let shortened: std::string::String = input.chars().take(max_chars).collect();
|
|
return format!("{shortened} …[truncated {} chars]", char_count - max_chars);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
#[derive(Debug)]
|
|
struct TestHttpServer {
|
|
url: std::string::String,
|
|
shutdown_tx: std::option::Option<tokio::sync::oneshot::Sender<()>>,
|
|
observed_methods: std::sync::Arc<tokio::sync::Mutex<std::vec::Vec<std::string::String>>>,
|
|
}
|
|
|
|
impl TestHttpServer {
|
|
async fn spawn() -> Self {
|
|
let observed_methods =
|
|
std::sync::Arc::new(tokio::sync::Mutex::new(std::vec::Vec::new()));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("listener bind must succeed");
|
|
let local_addr = listener.local_addr().expect("local addr must exist");
|
|
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let observed_methods_for_server = observed_methods.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut shutdown_rx => {
|
|
break;
|
|
},
|
|
accept_result = listener.accept() => {
|
|
let (mut stream, _peer_addr) = accept_result.expect("accept must succeed");
|
|
let observed_methods_for_connection = observed_methods_for_server.clone();
|
|
tokio::spawn(async move {
|
|
let mut buffer = vec![0u8; 65536];
|
|
let read_result = stream.read(&mut buffer).await;
|
|
let bytes_read = read_result.expect("read must succeed");
|
|
let request_text =
|
|
std::string::String::from_utf8_lossy(&buffer[..bytes_read]).to_string();
|
|
let split_result: std::vec::Vec<&str> =
|
|
request_text.split("\r\n\r\n").collect();
|
|
let body = if split_result.len() >= 2 {
|
|
split_result[1].to_string()
|
|
} else {
|
|
std::string::String::new()
|
|
};
|
|
let request_json: serde_json::Value =
|
|
serde_json::from_str(&body).expect("request body must be valid json");
|
|
let method = request_json["method"]
|
|
.as_str()
|
|
.expect("method must be a string")
|
|
.to_string();
|
|
{
|
|
let mut observed_methods_guard =
|
|
observed_methods_for_connection.lock().await;
|
|
observed_methods_guard.push(method.clone());
|
|
}
|
|
let id = request_json["id"].clone();
|
|
if method == "rateLimitMe" {
|
|
let response_body = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"error": {
|
|
"code": 42900,
|
|
"message": "Too many requests"
|
|
},
|
|
"id": id
|
|
}).to_string();
|
|
let response_text = format!(
|
|
"HTTP/1.1 429 TOO MANY REQUESTS\r\nContent-Type: application/json\r\nRetry-After: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
response_body.len(),
|
|
response_body
|
|
);
|
|
let _ = stream.write_all(response_text.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
return;
|
|
}
|
|
let response_body = if method == "getHealth" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": "ok",
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getVersion" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": {
|
|
"solana-core": "2.2.3",
|
|
"feature-set": 123
|
|
},
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getSlot" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": 424242u64,
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getBlockHeight" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": 919191u64,
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getLatestBlockhash" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": {
|
|
"context": {
|
|
"slot": 999u64
|
|
},
|
|
"value": {
|
|
"blockhash": crate::SYSTEM_PROGRAM_ID,
|
|
"lastValidBlockHeight": 12345u64
|
|
}
|
|
},
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getBalance" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": {
|
|
"context": {
|
|
"slot": 77u64
|
|
},
|
|
"value": 5000u64
|
|
},
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getSignaturesForAddress" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": [
|
|
{
|
|
"signature": "5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7",
|
|
"slot": 88u64,
|
|
"err": null,
|
|
"memo": null,
|
|
"blockTime": 1700000000i64,
|
|
"confirmationStatus": "finalized"
|
|
}
|
|
],
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getTransaction" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": null,
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getAccountInfo" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": {
|
|
"context": {
|
|
"slot": 55u64
|
|
},
|
|
"value": {
|
|
"data": ["", "base64"],
|
|
"executable": false,
|
|
"lamports": 1u64,
|
|
"owner": crate::SYSTEM_PROGRAM_ID,
|
|
"rentEpoch": 0u64,
|
|
"space": 0u64
|
|
}
|
|
},
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "getProgramAccounts" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": [],
|
|
"id": id
|
|
}).to_string()
|
|
} else if method == "sendTransaction" {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"result": "signature-test",
|
|
"id": id
|
|
}).to_string()
|
|
} else {
|
|
serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"error": {
|
|
"code": -32601,
|
|
"message": "Method not found"
|
|
},
|
|
"id": id
|
|
}).to_string()
|
|
};
|
|
let response_text = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
response_body.len(),
|
|
response_body
|
|
);
|
|
let _ = stream.write_all(response_text.as_bytes()).await;
|
|
let _ = stream.shutdown().await;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return Self {
|
|
url: format!("http://{}", local_addr),
|
|
shutdown_tx: Some(shutdown_tx),
|
|
observed_methods,
|
|
};
|
|
}
|
|
|
|
async fn observed_methods_snapshot(&self) -> std::vec::Vec<std::string::String> {
|
|
let observed_methods_guard = self.observed_methods.lock().await;
|
|
return observed_methods_guard.clone();
|
|
}
|
|
|
|
async fn shutdown(mut self) {
|
|
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
|
let _ = shutdown_tx.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn make_http_endpoint(url: std::string::String) -> crate::HttpEndpointConfig {
|
|
return crate::HttpEndpointConfig {
|
|
name: "test_http".to_string(),
|
|
enabled: true,
|
|
provider: "test".to_string(),
|
|
url,
|
|
api_key_env_var: None,
|
|
roles: vec!["http_queries".to_string()],
|
|
requests_per_second: 20,
|
|
burst_capacity: 5,
|
|
send_transaction_requests_per_second: Some(2),
|
|
send_transaction_burst_capacity: Some(1),
|
|
heavy_requests_per_second: Some(1),
|
|
heavy_burst_capacity: Some(1),
|
|
connect_timeout_ms: 2000,
|
|
request_timeout_ms: 2000,
|
|
max_idle_connections_per_host: 4,
|
|
pause_after_http_429_ms: Some(1500),
|
|
max_concurrent_requests_per_endpoint: 2,
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn parse_http_success_response_works() {
|
|
let parsed =
|
|
crate::parse_json_rpc_http_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":1}"#)
|
|
.expect("parse must succeed");
|
|
match parsed {
|
|
crate::JsonRpcHttpResponse::Success(response) => {
|
|
assert_eq!(response.result, serde_json::Value::String("ok".to_string()));
|
|
assert_eq!(response.id, serde_json::Value::from(1u64));
|
|
},
|
|
other => {
|
|
panic!("unexpected response: {other:?}");
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_http_error_response_works() {
|
|
let parsed = crate::parse_json_rpc_http_response_text(
|
|
r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}"#,
|
|
)
|
|
.expect("parse must succeed");
|
|
match parsed {
|
|
crate::JsonRpcHttpResponse::Error(response) => {
|
|
assert_eq!(response.error.code, -32601);
|
|
assert_eq!(response.error.message, "Method not found");
|
|
},
|
|
other => {
|
|
panic!("unexpected response: {other:?}");
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_method_distinguishes_general_send_and_heavy() {
|
|
assert_eq!(
|
|
crate::HttpClient::classify_method("getSlot"),
|
|
crate::HttpMethodClass::GeneralRpc
|
|
);
|
|
assert_eq!(
|
|
crate::HttpClient::classify_method("sendTransaction"),
|
|
crate::HttpMethodClass::SendTransaction
|
|
);
|
|
assert_eq!(
|
|
crate::HttpClient::classify_method("getProgramAccounts"),
|
|
crate::HttpMethodClass::HeavyRead
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn next_request_id_is_shared_between_clones() {
|
|
let endpoint = make_http_endpoint("http://127.0.0.1:65535".to_string());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
let cloned = client.clone();
|
|
assert_eq!(client.next_request_id(), 1);
|
|
assert_eq!(cloned.next_request_id(), 2);
|
|
assert_eq!(client.next_request_id(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn manual_pause_and_resume_work() {
|
|
let endpoint = make_http_endpoint("http://127.0.0.1:65535".to_string());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
let initial_status = client.endpoint_status().await;
|
|
assert_eq!(initial_status, crate::HttpEndpointStatus::Active);
|
|
client.pause_for(25).await;
|
|
let paused_status = client.endpoint_status().await;
|
|
match paused_status {
|
|
crate::HttpEndpointStatus::Paused { remaining_ms } => {
|
|
assert!(remaining_ms > 0);
|
|
},
|
|
other => {
|
|
panic!("unexpected status: {other:?}");
|
|
},
|
|
}
|
|
client.resume().await;
|
|
let resumed_status = client.endpoint_status().await;
|
|
assert_eq!(resumed_status, crate::HttpEndpointStatus::Active);
|
|
client.disable().await;
|
|
let disabled_status = client.endpoint_status().await;
|
|
assert_eq!(disabled_status, crate::HttpEndpointStatus::Disabled);
|
|
client.enable().await;
|
|
let enabled_status = client.endpoint_status().await;
|
|
assert_eq!(enabled_status, crate::HttpEndpointStatus::Active);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn typed_helpers_work_for_basic_methods() {
|
|
let server = TestHttpServer::spawn().await;
|
|
let endpoint = make_http_endpoint(server.url.clone());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
let health = client.get_health().await.expect("get_health must succeed");
|
|
assert_eq!(health, "ok".to_string());
|
|
let version = client.get_version().await.expect("get_version must succeed");
|
|
assert_eq!(version.solana_core, "2.2.3".to_string());
|
|
let slot = client.get_slot(None).await.expect("get_slot must succeed");
|
|
assert_eq!(slot, 424242u64);
|
|
let block_height =
|
|
client.get_block_height(None).await.expect("get_block_height must succeed");
|
|
assert_eq!(block_height, 919191u64);
|
|
let latest_blockhash = client
|
|
.get_latest_blockhash(None)
|
|
.await
|
|
.expect("get_latest_blockhash must succeed");
|
|
assert_eq!(latest_blockhash.context.slot, 999u64);
|
|
assert_eq!(latest_blockhash.value.blockhash, crate::SYSTEM_PROGRAM_ID.to_string());
|
|
assert_eq!(latest_blockhash.value.last_valid_block_height, 12345u64);
|
|
let balance = client
|
|
.get_balance(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_balance must succeed");
|
|
assert_eq!(balance.context.slot, 77u64);
|
|
assert_eq!(balance.value, 5000u64);
|
|
let signatures = client
|
|
.get_signatures_for_address(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_signatures_for_address must succeed");
|
|
assert_eq!(signatures.len(), 1);
|
|
assert_eq!(signatures[0].slot, 88u64);
|
|
let transaction = client
|
|
.get_transaction(
|
|
"5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("get_transaction must succeed");
|
|
assert!(transaction.is_none());
|
|
let sent_signature = client
|
|
.send_transaction("AAAA".to_string(), None)
|
|
.await
|
|
.expect("send_transaction must succeed");
|
|
assert_eq!(sent_signature, "signature-test".to_string());
|
|
let observed_methods = server.observed_methods_snapshot().await;
|
|
assert!(observed_methods.iter().any(|method| return method == "getHealth"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getVersion"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getSlot"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getBlockHeight"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getLatestBlockhash"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getBalance"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getSignaturesForAddress"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getTransaction"));
|
|
assert!(observed_methods.iter().any(|method| return method == "sendTransaction"));
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn raw_helpers_send_expected_methods() {
|
|
let server = TestHttpServer::spawn().await;
|
|
let endpoint = make_http_endpoint(server.url.clone());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
|
|
let _ = client.get_health_raw().await.expect("get_health_raw must succeed");
|
|
let _ = client.get_version_raw().await.expect("get_version_raw must succeed");
|
|
let _ = client.get_slot_raw(None).await.expect("get_slot_raw must succeed");
|
|
let _ = client
|
|
.get_block_height_raw(None)
|
|
.await
|
|
.expect("get_block_height_raw must succeed");
|
|
let _ = client
|
|
.get_latest_blockhash_raw(None)
|
|
.await
|
|
.expect("get_latest_blockhash_raw must succeed");
|
|
let _ = client
|
|
.get_balance_raw(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_balance_raw must succeed");
|
|
let _ = client
|
|
.get_account_info_raw(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_account_info_raw must succeed");
|
|
let _ = client
|
|
.get_program_accounts_raw(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_program_accounts_raw must succeed");
|
|
let _ = client
|
|
.get_signatures_for_address_raw(crate::SYSTEM_PROGRAM_ID.to_string(), None)
|
|
.await
|
|
.expect("get_signatures_for_address_raw must succeed");
|
|
let _ = client
|
|
.get_transaction_raw(
|
|
"5h6xBEauJ3PK6SWC7r7J2W8mE1D7aQj4J6Jg8n1SmWnVqSg9H6gq2K7xwJkL2GZ2RZ6n9wYk9cW1b2V3a4d5e6f7".to_string(),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("get_transaction_raw must succeed");
|
|
let _ = client
|
|
.send_transaction_raw("AAAA".to_string(), None)
|
|
.await
|
|
.expect("send_transaction_raw must succeed");
|
|
let observed_methods = server.observed_methods_snapshot().await;
|
|
assert!(observed_methods.iter().any(|method| return method == "getHealth"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getVersion"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getSlot"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getBlockHeight"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getLatestBlockhash"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getBalance"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getAccountInfo"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getProgramAccounts"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getSignaturesForAddress"));
|
|
assert!(observed_methods.iter().any(|method| return method == "getTransaction"));
|
|
assert!(observed_methods.iter().any(|method| return method == "sendTransaction"));
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn http_429_triggers_local_pause() {
|
|
let server = TestHttpServer::spawn().await;
|
|
let endpoint = make_http_endpoint(server.url.clone());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
let result = client
|
|
.execute_json_rpc_request_raw("rateLimitMe".to_string(), std::vec::Vec::new())
|
|
.await;
|
|
assert!(result.is_err());
|
|
let paused_status = client.endpoint_status().await;
|
|
match paused_status {
|
|
crate::HttpEndpointStatus::Paused { remaining_ms } => {
|
|
assert!(remaining_ms > 0);
|
|
},
|
|
other => {
|
|
panic!("unexpected status after 429: {other:?}");
|
|
},
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
|
let resumed_status = client.endpoint_status().await;
|
|
assert_eq!(resumed_status, crate::HttpEndpointStatus::Active);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_endpoint_rejects_requests() {
|
|
let endpoint = make_http_endpoint("http://127.0.0.1:65535".to_string());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
client.disable().await;
|
|
let result = client.get_health_raw().await;
|
|
assert!(result.is_err());
|
|
let error = result.expect_err("disabled endpoint must reject requests");
|
|
match error {
|
|
crate::Error::Http(message) => {
|
|
assert!(message.contains("is disabled"));
|
|
},
|
|
other => {
|
|
panic!("unexpected error: {other:?}");
|
|
},
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_method_returns_error() {
|
|
let server = TestHttpServer::spawn().await;
|
|
let endpoint = make_http_endpoint(server.url.clone());
|
|
let client = crate::HttpClient::new(endpoint).expect("client creation must succeed");
|
|
let result = client
|
|
.execute_json_rpc_request_raw("unknownMethod".to_string(), std::vec::Vec::new())
|
|
.await;
|
|
assert!(result.is_err());
|
|
let error = result.expect_err("unknown method must fail");
|
|
match error {
|
|
crate::Error::Http(message) => {
|
|
assert!(message.contains("Method not found"));
|
|
},
|
|
other => {
|
|
panic!("unexpected error: {other:?}");
|
|
},
|
|
}
|
|
server.shutdown().await;
|
|
}
|
|
}
|