v0.2.1-pre.002
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 96
|
# version: 97
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.1-pre.1"
|
version = "0.2.1-pre.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -18,6 +18,7 @@ fs2 = { version = "^0.4" }
|
|||||||
serde = { version = "^1.0", features = ["derive"] }
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
jsonschema = { version = "^0.49", default-features = false }
|
jsonschema = { version = "^0.49", default-features = false }
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
solana-pubkey = { version = "^4.3", default-features = false }
|
solana-pubkey = { version = "^4.3", default-features = false }
|
||||||
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
tracing = { version = "^0.1", default-features = false, features = ["std"] }
|
||||||
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: ROADMAP.md -->
|
<!-- file: ROADMAP.md -->
|
||||||
<!-- version: 26 -->
|
<!-- version: 27 -->
|
||||||
|
|
||||||
# Roadmap KSP
|
# Roadmap KSP
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ Le roadmap décrit les objectifs à atteindre et les grandes étapes prévues. U
|
|||||||
- [X] `0.2.0-rel.001` — Publication stable du cadrage `0.2.x`; prochaine release : `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation`.
|
- [X] `0.2.0-rel.001` — Publication stable du cadrage `0.2.x`; prochaine release : `0.2.1 — ksp-onchain-transport-lib / HTTP Solana foundation`.
|
||||||
- [X] `0.2.1-pre.001` — Audit HTTP officiel/bot3, matrice exhaustive, architecture et gate de sizing : scope monolithique refusé, premier split de la couverture HTTP sur `0.2.1`–`0.2.6`, `0.2.1` réduite déclarée raisonnablement clôturable.
|
- [X] `0.2.1-pre.001` — Audit HTTP officiel/bot3, matrice exhaustive, architecture et gate de sizing : scope monolithique refusé, premier split de la couverture HTTP sur `0.2.1`–`0.2.6`, `0.2.1` réduite déclarée raisonnablement clôturable.
|
||||||
- [X] `0.2.1-pre.001-fix.001` — Recalibrer le split HTTP sur `0.2.1`–`0.2.4` : `0.2.2` Accounts + Tokens + Cluster, `0.2.3` Transactions, `0.2.4` Blocks + Economics + compliance finale ; autoriser l'enchaînement de plusieurs releases complètement clôturées dans une même session lorsque le sizing le permet.
|
- [X] `0.2.1-pre.001-fix.001` — Recalibrer le split HTTP sur `0.2.1`–`0.2.4` : `0.2.2` Accounts + Tokens + Cluster, `0.2.3` Transactions, `0.2.4` Blocks + Economics + compliance finale ; autoriser l'enchaînement de plusieurs releases complètement clôturées dans une même session lorsque le sizing le permet.
|
||||||
|
- [X] `0.2.1-pre.002` — Créer la crate `ksp-onchain-transport-lib`, fixer ses settings runtime publics/validation, les envelopes JSON-RPC HTTP, les codes d'erreur, le registre central 52 current + 14 historiques et la base de warnings KSP de statut.
|
||||||
|
|
||||||
### Releases fonctionnelles décidées/pressenties
|
### Releases fonctionnelles décidées/pressenties
|
||||||
|
|
||||||
|
|||||||
18
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
18
crates/ksp-onchain-transport-lib/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
|
# version: 1
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "ksp-onchain-transport-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
27
crates/ksp-onchain-transport-lib/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// Error code used when HTTP transport runtime settings are invalid.
|
||||||
|
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
|
||||||
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
|
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||||
|
/// Error code used when an HTTP connection cannot be established.
|
||||||
|
pub const ERROR_CODE_HTTP_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_connection_failed");
|
||||||
|
/// Error code used when an HTTP request fails after a connection exists.
|
||||||
|
pub const ERROR_CODE_HTTP_REQUEST_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "http_request_failed");
|
||||||
|
/// Error code used when a transport deadline expires.
|
||||||
|
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||||
|
/// Error code used when an endpoint or provider rate-limits a request.
|
||||||
|
pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rate_limited");
|
||||||
|
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||||
|
pub const ERROR_CODE_JSON_ENCODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_encode_failed");
|
||||||
|
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||||
|
pub const ERROR_CODE_JSON_DECODE_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_decode_failed");
|
||||||
|
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||||
|
pub const ERROR_CODE_JSON_RPC_PROTOCOL_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "json_rpc_protocol_invalid");
|
||||||
|
/// Error code used when a remote JSON-RPC endpoint returns an application-level RPC error.
|
||||||
|
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||||
|
/// Error code used when a historically documented RPC method is no longer supported by the targeted runtime.
|
||||||
|
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
|
||||||
|
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
|
||||||
|
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
|
||||||
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
280
crates/ksp-onchain-transport-lib/src/json_rpc.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
const JSON_RPC_VERSION: &str = "2.0";
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||||
|
#[derive(Clone, PartialEq, serde::Serialize)]
|
||||||
|
pub struct JsonRpcRequest {
|
||||||
|
jsonrpc: &'static str,
|
||||||
|
id: u64,
|
||||||
|
method: std::string::String,
|
||||||
|
params: std::vec::Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcRequest {
|
||||||
|
/// Creates a JSON-RPC 2.0 request with a KSP-owned numeric identifier.
|
||||||
|
pub fn new(id: u64, method: impl std::convert::Into<std::string::String>, params: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<Self> {
|
||||||
|
let method = method.into();
|
||||||
|
if method.trim().is_empty() || method.trim() != method {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC method must be a non-empty trimmed string")
|
||||||
|
.with_context("field", "method"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return std::result::Result::Ok(Self { jsonrpc: JSON_RPC_VERSION, id, method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the numeric request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the RPC method name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn method(&self) -> &str {
|
||||||
|
return self.method.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns ordered request parameters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn params(&self) -> &[serde_json::Value] {
|
||||||
|
return self.params.as_slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the request into compact JSON text.
|
||||||
|
pub fn to_json_string(&self) -> ksp_core_lib::Result<std::string::String> {
|
||||||
|
let serialization_result = serde_json::to_string(self);
|
||||||
|
return match serialization_result {
|
||||||
|
std::result::Result::Ok(text) => std::result::Result::Ok(text),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "cannot encode JSON-RPC request")
|
||||||
|
.with_context("method", self.method())
|
||||||
|
.with_source(error),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcRequest {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("JsonRpcRequest")
|
||||||
|
.field("jsonrpc", &self.jsonrpc)
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("method", &self.method)
|
||||||
|
.field("param_count", &self.params.len())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||||
|
#[derive(Clone, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct JsonRpcErrorObject {
|
||||||
|
code: i64,
|
||||||
|
message: std::string::String,
|
||||||
|
#[serde(default)]
|
||||||
|
data: std::option::Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcErrorObject {
|
||||||
|
/// Returns the remote JSON-RPC application error code.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> i64 {
|
||||||
|
return self.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remote human-readable RPC error message.
|
||||||
|
#[must_use]
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
return self.message.as_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns optional provider-supplied error data.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn data(&self) -> std::option::Option<&serde_json::Value> {
|
||||||
|
return self.data.as_ref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcErrorObject {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter
|
||||||
|
.debug_struct("JsonRpcErrorObject")
|
||||||
|
.field("code", &self.code)
|
||||||
|
.field("message", &"<redacted>")
|
||||||
|
.field("data", &if self.data.is_some() { "present" } else { "absent" })
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 success response.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct JsonRpcSuccessResponse {
|
||||||
|
id: u64,
|
||||||
|
result: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcSuccessResponse {
|
||||||
|
/// Returns the echoed request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw JSON result, including JSON `null` when the method legitimately returns it.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn result(&self) -> &serde_json::Value {
|
||||||
|
return &self.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the response and returns its raw JSON result.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_result(self) -> serde_json::Value {
|
||||||
|
return self.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for JsonRpcSuccessResponse {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("JsonRpcSuccessResponse").field("id", &self.id).field("result", &"<omitted>").finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 error response.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct JsonRpcErrorResponse {
|
||||||
|
id: u64,
|
||||||
|
error: crate::JsonRpcErrorObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcErrorResponse {
|
||||||
|
/// Returns the echoed request identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> u64 {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remote JSON-RPC application error payload.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn error(&self) -> &crate::JsonRpcErrorObject {
|
||||||
|
return &self.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated JSON-RPC 2.0 HTTP response preserving success and application-error payloads separately.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum JsonRpcResponse {
|
||||||
|
/// Successful response containing a raw method result.
|
||||||
|
Success(crate::JsonRpcSuccessResponse),
|
||||||
|
/// Application-level JSON-RPC error returned by the remote endpoint.
|
||||||
|
Error(crate::JsonRpcErrorResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcResponse {
|
||||||
|
/// Converts a validated response into the raw success result or a KSP application-error classification.
|
||||||
|
pub fn into_result(self) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
return match self {
|
||||||
|
Self::Success(success) => std::result::Result::Ok(success.into_result()),
|
||||||
|
Self::Error(error_response) => std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_RPC_APPLICATION_ERROR, "Solana JSON-RPC endpoint returned an application error")
|
||||||
|
.with_context("rpc_code", error_response.error().code().to_string()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||||
|
pub fn parse_json_rpc_response_text(text: &str, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
let decode_result = serde_json::from_str::<serde_json::Value>(text);
|
||||||
|
let value = match decode_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_JSON_DECODE_FAILED, "cannot decode JSON-RPC response as JSON").with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return crate::parse_json_rpc_response_value(value, expected_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a decoded JSON value as one JSON-RPC HTTP response for the expected KSP request identifier.
|
||||||
|
pub fn parse_json_rpc_response_value(value: serde_json::Value, expected_id: u64) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
let object = match value.as_object() {
|
||||||
|
std::option::Option::Some(object) => object,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response must be an object", "response");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let version = match object.get("jsonrpc") {
|
||||||
|
std::option::Option::Some(serde_json::Value::String(version)) => version.as_str(),
|
||||||
|
std::option::Option::Some(_) => {
|
||||||
|
return protocol_error("JSON-RPC version must be a string", "jsonrpc");
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response is missing its version", "jsonrpc");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if version != JSON_RPC_VERSION {
|
||||||
|
return protocol_error("JSON-RPC version must be exactly 2.0", "jsonrpc");
|
||||||
|
}
|
||||||
|
let response_id = match object.get("id") {
|
||||||
|
std::option::Option::Some(id) => match id.as_u64() {
|
||||||
|
std::option::Option::Some(id) => id,
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response id must be an unsigned integer", "id");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC response is missing its id", "id");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if response_id != expected_id {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC response id does not match the request")
|
||||||
|
.with_context("expected_id", expected_id.to_string())
|
||||||
|
.with_context("actual_id", response_id.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let has_result = object.contains_key("result");
|
||||||
|
let has_error = object.contains_key("error");
|
||||||
|
if has_result == has_error {
|
||||||
|
return protocol_error("JSON-RPC response must contain exactly one of result or error", "response");
|
||||||
|
}
|
||||||
|
if has_result {
|
||||||
|
let result = match object.get("result") {
|
||||||
|
std::option::Option::Some(result) => result.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC result field disappeared during validation", "result");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::JsonRpcResponse::Success(crate::JsonRpcSuccessResponse { id: response_id, result }));
|
||||||
|
}
|
||||||
|
let error_value = match object.get("error") {
|
||||||
|
std::option::Option::Some(error) => error.clone(),
|
||||||
|
std::option::Option::None => {
|
||||||
|
return protocol_error("JSON-RPC error field disappeared during validation", "error");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error_decode_result = serde_json::from_value::<crate::JsonRpcErrorObject>(error_value);
|
||||||
|
let error = match error_decode_result {
|
||||||
|
std::result::Result::Ok(error) => error,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
return std::result::Result::Err(
|
||||||
|
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, "JSON-RPC error object is invalid")
|
||||||
|
.with_context("field", "error")
|
||||||
|
.with_source(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return std::result::Result::Ok(crate::JsonRpcResponse::Error(crate::JsonRpcErrorResponse { id: response_id, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_error(message: &str, field: &str) -> ksp_core_lib::Result<crate::JsonRpcResponse> {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID, message).with_context("field", field));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/json_rpc.rs"]
|
||||||
|
mod tests;
|
||||||
97
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
97
crates/ksp-onchain-transport-lib/src/lib.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
// version: 1
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![deny(unreachable_pub)]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
//! KSP-owned Solana on-chain transport foundation.
|
||||||
|
//!
|
||||||
|
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
|
||||||
|
//! independent from `ksp-config-lib`, Store and Program layers. Config may later construct these public settings through a one-way adapter, while network
|
||||||
|
//! clients, pools, resilience and typed Solana method adapters are introduced by subsequent `0.2.1` prereleases.
|
||||||
|
|
||||||
|
mod error;
|
||||||
|
mod json_rpc;
|
||||||
|
mod rpc_method;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
|
pub use self::error::ERROR_CODE_ENDPOINT_SELECTION_FAILED;
|
||||||
|
/// Error code used when an HTTP connection cannot be established.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
|
||||||
|
/// Error code used when an HTTP request fails after connection establishment.
|
||||||
|
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
|
||||||
|
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
|
||||||
|
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
|
||||||
|
/// Error code used when HTTP transport runtime settings are invalid.
|
||||||
|
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
|
||||||
|
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_DECODE_FAILED;
|
||||||
|
/// Error code used when a JSON-RPC request cannot be encoded.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_ENCODE_FAILED;
|
||||||
|
/// Error code used when a decoded JSON-RPC envelope violates protocol invariants.
|
||||||
|
pub use self::error::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID;
|
||||||
|
/// Error code used when a historically documented RPC method has been removed from the targeted runtime.
|
||||||
|
pub use self::error::ERROR_CODE_METHOD_REMOVED;
|
||||||
|
/// Error code used when an endpoint or provider rate-limits a request.
|
||||||
|
pub use self::error::ERROR_CODE_RATE_LIMITED;
|
||||||
|
/// Error code used when a remote endpoint returns an application-level JSON-RPC error.
|
||||||
|
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||||
|
/// Error code used when a transport deadline expires.
|
||||||
|
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||||
|
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||||
|
pub use self::json_rpc::JsonRpcErrorObject;
|
||||||
|
/// Validated JSON-RPC 2.0 error response.
|
||||||
|
pub use self::json_rpc::JsonRpcErrorResponse;
|
||||||
|
/// JSON-RPC 2.0 HTTP request envelope emitted by KSP.
|
||||||
|
pub use self::json_rpc::JsonRpcRequest;
|
||||||
|
/// Validated JSON-RPC 2.0 HTTP response.
|
||||||
|
pub use self::json_rpc::JsonRpcResponse;
|
||||||
|
/// Validated JSON-RPC 2.0 success response.
|
||||||
|
pub use self::json_rpc::JsonRpcSuccessResponse;
|
||||||
|
/// Parses and validates a JSON-RPC HTTP response from UTF-8 JSON text.
|
||||||
|
pub use self::json_rpc::parse_json_rpc_response_text;
|
||||||
|
/// Validates a decoded JSON value as one JSON-RPC HTTP response.
|
||||||
|
pub use self::json_rpc::parse_json_rpc_response_value;
|
||||||
|
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
|
||||||
|
pub use self::rpc_method::HttpRpcCategory;
|
||||||
|
/// Release that owns typed KSP coverage for one audited HTTP RPC method.
|
||||||
|
pub use self::rpc_method::HttpRpcCoverageRelease;
|
||||||
|
/// Immutable audited descriptor for one Solana HTTP JSON-RPC method.
|
||||||
|
pub use self::rpc_method::HttpRpcMethodDescriptor;
|
||||||
|
/// Documentation lifecycle status of one audited RPC method.
|
||||||
|
pub use self::rpc_method::RpcDocumentationStatus;
|
||||||
|
/// Technical operation kind used to separate reads, simulations and submissions.
|
||||||
|
pub use self::rpc_method::RpcOperationKind;
|
||||||
|
/// Request-form policy attached to a stable RPC method.
|
||||||
|
pub use self::rpc_method::RpcRequestFormStatus;
|
||||||
|
/// Runtime availability status of one audited RPC method.
|
||||||
|
pub use self::rpc_method::RpcRuntimeStatus;
|
||||||
|
/// HTTP transport retry classification attached to an RPC method descriptor.
|
||||||
|
pub use self::rpc_method::TransportRetryClass;
|
||||||
|
/// Returns all current Solana HTTP RPC method descriptors audited for the `0.2.1`–`0.2.4` coverage sequence.
|
||||||
|
pub use self::rpc_method::current_http_rpc_methods;
|
||||||
|
/// Finds a current or historical standard Solana HTTP RPC descriptor by exact method name.
|
||||||
|
pub use self::rpc_method::find_http_rpc_method;
|
||||||
|
/// Returns historically documented deprecated HTTP RPC descriptors retained for compliance history.
|
||||||
|
pub use self::rpc_method::historical_http_rpc_methods;
|
||||||
|
/// Open cluster or network descriptor used by HTTP endpoint settings.
|
||||||
|
pub use self::settings::HttpClusterName;
|
||||||
|
/// Runtime settings for one role declared by an HTTP endpoint.
|
||||||
|
pub use self::settings::HttpEndpointRoleSettings;
|
||||||
|
/// Runtime settings for one named Solana HTTP endpoint.
|
||||||
|
pub use self::settings::HttpEndpointSettings;
|
||||||
|
/// Runtime HTTP endpoint URL with redacted diagnostics.
|
||||||
|
pub use self::settings::HttpEndpointUrl;
|
||||||
|
/// Open provider descriptor used by HTTP endpoint settings.
|
||||||
|
pub use self::settings::HttpProviderName;
|
||||||
|
/// Open request-kind descriptor used by logical endpoint capabilities.
|
||||||
|
pub use self::settings::HttpRequestKind;
|
||||||
|
/// Bounded retry settings owned by the HTTP transport runtime.
|
||||||
|
pub use self::settings::HttpRetrySettings;
|
||||||
|
/// Local limits attached to one logical HTTP endpoint role.
|
||||||
|
pub use self::settings::HttpRoleLimits;
|
||||||
|
/// Open logical endpoint role descriptor.
|
||||||
|
pub use self::settings::HttpRoleName;
|
||||||
|
/// Complete runtime settings consumed by the Solana HTTP transport foundation.
|
||||||
|
pub use self::settings::HttpTransportSettings;
|
||||||
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
1108
crates/ksp-onchain-transport-lib/src/rpc_method.rs
Normal file
File diff suppressed because it is too large
Load Diff
578
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
578
crates/ksp-onchain-transport-lib/src/settings.rs
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/settings.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
/// 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<std::string::String>) -> ksp_core_lib::Result<Self> {
|
||||||
|
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(<redacted>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<std::string::String>) -> 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<std::string::String>) -> 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<std::string::String>) -> 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<std::string::String>) -> 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<std::num::NonZeroU32>,
|
||||||
|
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpRoleLimits {
|
||||||
|
/// Creates explicit role limits. `None` leaves the corresponding limit unbounded by this KSP transport layer.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(
|
||||||
|
requests_per_second: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
burst_capacity: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
max_concurrent_requests: std::option::Option<std::num::NonZeroU32>,
|
||||||
|
pause_after_rate_limit: std::option::Option<std::time::Duration>,
|
||||||
|
) -> 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<std::num::NonZeroU32> {
|
||||||
|
return self.requests_per_second;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured token-bucket burst capacity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn burst_capacity(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||||
|
return self.burst_capacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured maximum concurrent request count.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_concurrent_requests(&self) -> std::option::Option<std::num::NonZeroU32> {
|
||||||
|
return self.max_concurrent_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured cooldown applied after rate limiting.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn pause_after_rate_limit(&self) -> std::option::Option<std::time::Duration> {
|
||||||
|
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<crate::HttpRequestKind>,
|
||||||
|
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<crate::HttpRequestKind>,
|
||||||
|
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<usize>,
|
||||||
|
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpEndpointSettings {
|
||||||
|
/// Creates explicit runtime settings for one logical HTTP endpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
name: impl std::convert::Into<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<usize>,
|
||||||
|
roles: std::vec::Vec<crate::HttpEndpointRoleSettings>,
|
||||||
|
) -> 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<usize> {
|
||||||
|
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<crate::HttpEndpointSettings>,
|
||||||
|
retry: crate::HttpRetrySettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpTransportSettings {
|
||||||
|
/// Creates complete HTTP transport runtime settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(endpoints: std::vec::Vec<crate::HttpEndpointSettings>, 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: env!("CARGO_PKG_NAME"),
|
||||||
|
endpoint_count = self.endpoints.len(),
|
||||||
|
enabled_endpoint_count,
|
||||||
|
"validated HTTP transport settings"
|
||||||
|
);
|
||||||
|
return std::result::Result::Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if 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() {
|
||||||
|
if 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;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_manifest_preserves_ksp_dependency_firewall() {
|
||||||
|
let manifest_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||||
|
let manifest = std::fs::read_to_string(manifest_path).expect("transport manifest must be readable during integration tests");
|
||||||
|
for forbidden in ["ksp-config-lib", "ksp-store-api", "ksp-store-lib", "ksp-program-api", "ksp-program-lib", "tracing =", "tracing."] {
|
||||||
|
assert!(!manifest.contains(forbidden), "forbidden direct transport dependency detected: {forbidden}");
|
||||||
|
}
|
||||||
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
|
assert!(manifest.contains("reqwest.workspace = true"));
|
||||||
|
}
|
||||||
61
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
61
crates/ksp-onchain-transport-lib/tests/public_api.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_settings_contract_is_constructible_without_config_dependency() {
|
||||||
|
let url = ksp_onchain_transport_lib::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("public URL parser must accept Devnet endpoint");
|
||||||
|
let role = ksp_onchain_transport_lib::HttpEndpointRoleSettings::new(
|
||||||
|
ksp_onchain_transport_lib::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![ksp_onchain_transport_lib::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
ksp_onchain_transport_lib::HttpRoleLimits::new(
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
std::option::Option::None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let endpoint = ksp_onchain_transport_lib::HttpEndpointSettings::new(
|
||||||
|
"solana_devnet_public",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::HttpProviderName::new("solana-public"),
|
||||||
|
ksp_onchain_transport_lib::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = ksp_onchain_transport_lib::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
ksp_onchain_transport_lib::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||||
|
);
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_json_rpc_contract_round_trips_foundation_shape() {
|
||||||
|
let request = ksp_onchain_transport_lib::JsonRpcRequest::new(1, "getHealth", std::vec![]).expect("public request constructor must succeed");
|
||||||
|
let encoded = request.to_json_string().expect("public request must serialize");
|
||||||
|
assert!(encoded.contains("\"jsonrpc\":\"2.0\""));
|
||||||
|
let response =
|
||||||
|
ksp_onchain_transport_lib::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":1}"#, 1).expect("public parser must validate response");
|
||||||
|
let result = response.into_result().expect("success response must return result");
|
||||||
|
assert_eq!(result, serde_json::json!("ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_method_registry_exposes_current_and_historical_surfaces() {
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::current_http_rpc_methods().len(), 52);
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::historical_http_rpc_methods().len(), 14);
|
||||||
|
let removed = ksp_onchain_transport_lib::find_http_rpc_method("confirmTransaction").expect("historical method must be discoverable");
|
||||||
|
assert_eq!(removed.runtime_status(), ksp_onchain_transport_lib::RpcRuntimeStatus::Removed);
|
||||||
|
assert_eq!(removed.ensure_runtime_supported().expect_err("removed method must fail").code(), ksp_onchain_transport_lib::ERROR_CODE_METHOD_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_error_codes_share_the_core_error_domain() {
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_SETTINGS.domain(), "onchain_transport");
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_RPC_APPLICATION_ERROR.domain(), "onchain_transport");
|
||||||
|
}
|
||||||
117
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
117
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_serialization_matches_json_rpc_2_0_shape() {
|
||||||
|
let request = super::JsonRpcRequest::new(7, "getBalance", std::vec![serde_json::json!("Address111"), serde_json::json!({"commitment":"confirmed"})])
|
||||||
|
.expect("valid request must construct");
|
||||||
|
let encoded = request.to_json_string().expect("serializable request must encode");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(encoded.as_str()).expect("encoded request must remain JSON");
|
||||||
|
assert_eq!(value["jsonrpc"], serde_json::json!("2.0"));
|
||||||
|
assert_eq!(value["id"], serde_json::json!(7));
|
||||||
|
assert_eq!(value["method"], serde_json::json!("getBalance"));
|
||||||
|
assert_eq!(value["params"].as_array().expect("params must be an array").len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_rejects_empty_or_untrimmed_method() {
|
||||||
|
assert!(super::JsonRpcRequest::new(1, "", std::vec![]).is_err());
|
||||||
|
assert!(super::JsonRpcRequest::new(1, " getHealth", std::vec![]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_preserves_null_success_result() {
|
||||||
|
let response = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":null,"id":9}"#, 9).expect("null result is a valid success payload");
|
||||||
|
match response {
|
||||||
|
super::JsonRpcResponse::Success(success) => assert!(success.result().is_null()),
|
||||||
|
super::JsonRpcResponse::Error(_) => assert!(false, "success response must not parse as error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_preserves_rpc_error_payload() {
|
||||||
|
let response = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is unhealthy","data":{"numSlotsBehind":12}},"id":4}"#,
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
.expect("valid JSON-RPC error envelope must parse");
|
||||||
|
match response {
|
||||||
|
super::JsonRpcResponse::Error(error_response) => {
|
||||||
|
assert_eq!(error_response.error().code(), -32005);
|
||||||
|
assert_eq!(error_response.error().message(), "Node is unhealthy");
|
||||||
|
assert_eq!(error_response.error().data(), std::option::Option::Some(&serde_json::json!({"numSlotsBehind":12})));
|
||||||
|
},
|
||||||
|
super::JsonRpcResponse::Success(_) => assert!(false, "RPC error response must not parse as success"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_id_mismatch() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","id":2}"#, 1).expect_err("mismatched id must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_wrong_protocol_version() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"1.0","result":"ok","id":1}"#, 1).expect_err("wrong version must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_both_result_and_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":"ok","error":{"code":-1,"message":"bad"},"id":1}"#, 1)
|
||||||
|
.expect_err("mutually exclusive fields must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_rejects_missing_result_and_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","id":1}"#, 1).expect_err("missing outcome must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_RPC_PROTOCOL_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_parser_distinguishes_invalid_json_from_protocol_error() {
|
||||||
|
let error = super::parse_json_rpc_response_text("not-json", 1).expect_err("invalid JSON must fail decoding");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_JSON_DECODE_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rpc_error_maps_to_shared_ksp_error_without_copying_remote_payload_into_context() {
|
||||||
|
let response = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"SECRET-CANARY","data":{"payload":"SECRET-DATA"}},"id":1}"#,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("valid error envelope must parse");
|
||||||
|
let error = response.into_result().expect_err("RPC application error must map to KSP error");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||||
|
let rendered = format!("{error:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("SECRET-DATA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_debug_omits_parameter_payloads() {
|
||||||
|
let request = super::JsonRpcRequest::new(1, "sendTransaction", std::vec![serde_json::json!("SIGNED-TRANSACTION-SECRET-CANARY")])
|
||||||
|
.expect("test request must construct");
|
||||||
|
let rendered = format!("{request:?}");
|
||||||
|
assert!(rendered.contains("sendTransaction"));
|
||||||
|
assert!(rendered.contains("param_count"));
|
||||||
|
assert!(!rendered.contains("SIGNED-TRANSACTION-SECRET-CANARY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_debug_omits_result_and_remote_error_payloads() {
|
||||||
|
let success = super::parse_json_rpc_response_text(r#"{"jsonrpc":"2.0","result":{"secret":"RESULT-SECRET-CANARY"},"id":1}"#, 1)
|
||||||
|
.expect("test success response must parse");
|
||||||
|
let success_rendered = format!("{success:?}");
|
||||||
|
assert!(!success_rendered.contains("RESULT-SECRET-CANARY"));
|
||||||
|
let failure = super::parse_json_rpc_response_text(
|
||||||
|
r#"{"jsonrpc":"2.0","error":{"code":-32000,"message":"MESSAGE-SECRET-CANARY","data":{"secret":"DATA-SECRET-CANARY"}},"id":2}"#,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.expect("test error response must parse");
|
||||||
|
let failure_rendered = format!("{failure:?}");
|
||||||
|
assert!(!failure_rendered.contains("MESSAGE-SECRET-CANARY"));
|
||||||
|
assert!(!failure_rendered.contains("DATA-SECRET-CANARY"));
|
||||||
|
}
|
||||||
121
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
121
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audited_registry_has_expected_current_and_historical_counts() {
|
||||||
|
assert_eq!(super::current_http_rpc_methods().len(), 52);
|
||||||
|
assert_eq!(super::historical_http_rpc_methods().len(), 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audited_registry_method_names_are_unique() {
|
||||||
|
let mut names = std::collections::BTreeSet::<&str>::new();
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
assert!(names.insert(descriptor.method()), "duplicate current method {}", descriptor.method());
|
||||||
|
}
|
||||||
|
for descriptor in super::historical_http_rpc_methods() {
|
||||||
|
assert!(names.insert(descriptor.method()), "duplicate historical method {}", descriptor.method());
|
||||||
|
}
|
||||||
|
assert_eq!(names.len(), 66);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_release_counts_match_recalibrated_matrix() {
|
||||||
|
let mut foundation = 0_usize;
|
||||||
|
let mut accounts_tokens_cluster = 0_usize;
|
||||||
|
let mut transactions = 0_usize;
|
||||||
|
let mut blocks_economics = 0_usize;
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
match descriptor.coverage_release() {
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_1 => foundation += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_2 => accounts_tokens_cluster += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_3 => transactions += 1,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_4 => blocks_economics += 1,
|
||||||
|
super::HttpRpcCoverageRelease::Historical => assert!(false, "current method must not be historical"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(foundation, 4);
|
||||||
|
assert_eq!(accounts_tokens_cluster, 22);
|
||||||
|
assert_eq!(transactions, 11);
|
||||||
|
assert_eq!(blocks_economics, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foundation_canary_assignment_is_exact() {
|
||||||
|
let mut names = std::vec::Vec::<&str>::new();
|
||||||
|
for descriptor in super::current_http_rpc_methods() {
|
||||||
|
if descriptor.coverage_release() == super::HttpRpcCoverageRelease::V0_2_1 {
|
||||||
|
names.push(descriptor.method());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_unstable();
|
||||||
|
assert_eq!(names, std::vec!["getBalance", "getGenesisHash", "getHealth", "getVersion"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_methods_are_deprecated_removed_and_not_retryable() {
|
||||||
|
for descriptor in super::historical_http_rpc_methods() {
|
||||||
|
assert_eq!(descriptor.documentation_status(), super::RpcDocumentationStatus::Deprecated);
|
||||||
|
assert_eq!(descriptor.runtime_status(), super::RpcRuntimeStatus::Removed);
|
||||||
|
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NotApplicable);
|
||||||
|
assert_eq!(descriptor.coverage_release(), super::HttpRpcCoverageRelease::Historical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_transaction_and_get_block_track_deprecated_legacy_request_form() {
|
||||||
|
let get_transaction = super::find_http_rpc_method("getTransaction").expect("getTransaction descriptor must exist");
|
||||||
|
let get_block = super::find_http_rpc_method("getBlock").expect("getBlock descriptor must exist");
|
||||||
|
assert!(get_transaction.request_form_status().has_deprecated_legacy());
|
||||||
|
assert!(get_block.request_form_status().has_deprecated_legacy());
|
||||||
|
let get_balance = super::find_http_rpc_method("getBalance").expect("getBalance descriptor must exist");
|
||||||
|
assert!(!get_balance.request_form_status().has_deprecated_legacy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_submission_methods_are_never_retry_after_ambiguous_dispatch() {
|
||||||
|
for method in ["sendTransaction", "requestAirdrop"] {
|
||||||
|
let descriptor = super::find_http_rpc_method(method).expect("write descriptor must exist");
|
||||||
|
assert_eq!(descriptor.operation_kind(), super::RpcOperationKind::WriteSubmission);
|
||||||
|
assert_eq!(descriptor.transport_retry_class(), super::TransportRetryClass::NeverAfterDispatch);
|
||||||
|
}
|
||||||
|
let simulation = super::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must exist");
|
||||||
|
assert_eq!(simulation.operation_kind(), super::RpcOperationKind::Simulation);
|
||||||
|
assert_eq!(simulation.transport_retry_class(), super::TransportRetryClass::RetrySafe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_method_support_check_returns_method_removed_error() {
|
||||||
|
let descriptor = super::find_http_rpc_method("confirmTransaction").expect("historical descriptor must exist");
|
||||||
|
let error = descriptor.ensure_runtime_supported().expect_err("removed method must not be callable");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_METHOD_REMOVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_supported_method_passes_runtime_support_check() {
|
||||||
|
let descriptor = super::find_http_rpc_method("getHealth").expect("current descriptor must exist");
|
||||||
|
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn supported_unstable_descriptor_executes_central_warning_path() {
|
||||||
|
let descriptor = super::HttpRpcMethodDescriptor::new(
|
||||||
|
"experimentalMethod",
|
||||||
|
super::HttpRpcCategory::Cluster,
|
||||||
|
"experimental_method",
|
||||||
|
super::RpcDocumentationStatus::Unstable,
|
||||||
|
super::RpcRuntimeStatus::Supported,
|
||||||
|
super::RpcRequestFormStatus::Stable,
|
||||||
|
super::RpcOperationKind::Read,
|
||||||
|
super::TransportRetryClass::RetrySafe,
|
||||||
|
std::option::Option::None,
|
||||||
|
super::HttpRpcCoverageRelease::V0_2_1,
|
||||||
|
);
|
||||||
|
assert!(descriptor.requires_method_usage_warning());
|
||||||
|
assert!(descriptor.ensure_runtime_supported().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_rejects_unknown_method_without_affecting_raw_provider_extensions() {
|
||||||
|
assert!(super::find_http_rpc_method("providerCustomMethod").is_none());
|
||||||
|
}
|
||||||
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
207
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
fn non_zero(value: u32) -> std::num::NonZeroU32 {
|
||||||
|
return std::num::NonZeroU32::new(value).expect("test non-zero value must remain non-zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_settings(url_text: &str) -> super::HttpTransportSettings {
|
||||||
|
let url = super::HttpEndpointUrl::parse(url_text).expect("test URL must be valid");
|
||||||
|
let limits = super::HttpRoleLimits::new(
|
||||||
|
std::option::Option::Some(non_zero(10)),
|
||||||
|
std::option::Option::Some(non_zero(20)),
|
||||||
|
std::option::Option::Some(non_zero(4)),
|
||||||
|
std::option::Option::Some(std::time::Duration::from_millis(500)),
|
||||||
|
);
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(super::HttpRoleName::new("default"), true, std::vec![super::HttpRequestKind::wildcard()], 100, limits);
|
||||||
|
let endpoint = super::HttpEndpointSettings::new(
|
||||||
|
"devnet_public",
|
||||||
|
true,
|
||||||
|
super::HttpProviderName::new("solana-public"),
|
||||||
|
super::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
std::option::Option::Some(8),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
return super::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
super::HttpRetrySettings::new(2, std::time::Duration::from_millis(100), std::time::Duration::from_secs(2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_accepts_http_and_https() {
|
||||||
|
assert!(super::HttpEndpointUrl::parse("https://api.devnet.solana.com").is_ok());
|
||||||
|
assert!(super::HttpEndpointUrl::parse("http://127.0.0.1:8899").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_rejects_non_http_schemes() {
|
||||||
|
let result = super::HttpEndpointUrl::parse("ws://api.devnet.solana.com");
|
||||||
|
let error = result.expect_err("WebSocket URL must not be accepted by HTTP settings");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_url_debug_redacts_secret_material() {
|
||||||
|
let url = super::HttpEndpointUrl::parse("https://provider.invalid/rpc?api-key=SECRET-CANARY").expect("test URL must parse");
|
||||||
|
let rendered = format!("{url:?}");
|
||||||
|
assert!(rendered.contains("<redacted>"));
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_transport_settings_pass_validation() {
|
||||||
|
let settings = valid_settings("https://api.devnet.solana.com");
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_debug_does_not_leak_endpoint_url() {
|
||||||
|
let settings = valid_settings("https://provider.invalid/rpc?api-key=SECRET-CANARY");
|
||||||
|
let rendered = format!("{settings:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("provider.invalid"));
|
||||||
|
assert!(rendered.contains("HttpEndpointUrl(<redacted>)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_require_one_enabled_endpoint() {
|
||||||
|
let url = super::HttpEndpointUrl::parse("https://api.devnet.solana.com").expect("test URL must parse");
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let endpoint = super::HttpEndpointSettings::new(
|
||||||
|
"disabled",
|
||||||
|
false,
|
||||||
|
super::HttpProviderName::new("provider"),
|
||||||
|
super::HttpClusterName::new("devnet"),
|
||||||
|
url,
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
std::option::Option::None,
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(
|
||||||
|
std::vec![endpoint],
|
||||||
|
super::HttpRetrySettings::new(1, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
|
||||||
|
);
|
||||||
|
let error = settings.validate().expect_err("all-disabled settings must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_duplicate_endpoint_names() {
|
||||||
|
let first = valid_settings("https://one.invalid");
|
||||||
|
let second = valid_settings("https://two.invalid");
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![first.endpoints()[0].clone(), second.endpoints()[0].clone()], first.retry().clone());
|
||||||
|
let error = settings.validate().expect_err("duplicate endpoint names must fail");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_duplicate_roles() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let duplicated_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![endpoint.roles()[0].clone(), endpoint.roles()[0].clone()],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![duplicated_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_wildcard_mixed_with_specific_kind() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard(), super::HttpRequestKind::new("get_balance")],
|
||||||
|
100,
|
||||||
|
endpoint.roles()[0].limits().clone(),
|
||||||
|
);
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_burst_without_rps() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let role = super::HttpEndpointRoleSettings::new(
|
||||||
|
super::HttpRoleName::new("default"),
|
||||||
|
true,
|
||||||
|
std::vec![super::HttpRequestKind::wildcard()],
|
||||||
|
100,
|
||||||
|
super::HttpRoleLimits::new(std::option::Option::None, std::option::Option::Some(non_zero(2)), std::option::Option::None, std::option::Option::None),
|
||||||
|
);
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
endpoint.request_timeout(),
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
std::vec![role],
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_reversed_retry_backoff() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let settings = super::HttpTransportSettings::new(
|
||||||
|
base.endpoints().to_vec(),
|
||||||
|
super::HttpRetrySettings::new(2, std::time::Duration::from_secs(2), std::time::Duration::from_secs(1)),
|
||||||
|
);
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_settings_reject_zero_request_timeout() {
|
||||||
|
let base = valid_settings("https://api.devnet.solana.com");
|
||||||
|
let endpoint = &base.endpoints()[0];
|
||||||
|
let modified_endpoint = super::HttpEndpointSettings::new(
|
||||||
|
endpoint.name(),
|
||||||
|
true,
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.url().clone(),
|
||||||
|
endpoint.connect_timeout(),
|
||||||
|
std::time::Duration::ZERO,
|
||||||
|
endpoint.max_idle_connections_per_host(),
|
||||||
|
endpoint.roles().to_vec(),
|
||||||
|
);
|
||||||
|
let settings = super::HttpTransportSettings::new(std::vec![modified_endpoint], base.retry().clone());
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
}
|
||||||
333
deltas/0.2.1/pre.002.md
Normal file
333
deltas/0.2.1/pre.002.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<!-- file: deltas/0.2.1/pre.002.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.1-pre.002` — fondation crate/settings/JSON-RPC/descriptors
|
||||||
|
|
||||||
|
## Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
release : 0.2.1
|
||||||
|
prerelease : pre.002
|
||||||
|
identifiant de commit attendu : v0.2.1-pre.002
|
||||||
|
workspace.package.version : 0.2.1-pre.2
|
||||||
|
base : v0.2.1-pre.001-fix.001
|
||||||
|
```
|
||||||
|
|
||||||
|
Le user a confirmé avoir commité `v0.2.1-pre.001` puis `v0.2.1-pre.001-fix.001` avant l'ouverture de cette tranche.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Matérialiser la fondation Rust de `ksp-onchain-transport-lib` sans ouvrir encore le client/pool HTTP :
|
||||||
|
|
||||||
|
- ajouter la crate au workspace ;
|
||||||
|
- stabiliser les codes d'erreur Transport ;
|
||||||
|
- posséder les settings runtime publics et leur validation ;
|
||||||
|
- posséder les envelopes JSON-RPC 2.0 HTTP ;
|
||||||
|
- matérialiser le registre central des méthodes/statuts issu de la matrice `pre.001` ;
|
||||||
|
- installer la base de warnings KSP de statut sans dépendance directe à `tracing` ;
|
||||||
|
- ajouter les tests unitaires/intégration correspondant à ces contrats.
|
||||||
|
|
||||||
|
## Version Cargo
|
||||||
|
|
||||||
|
Conformément à `VER-ID-009` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.1 -> 0.2.1-pre.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les crates membres continuent d'hériter `version.workspace = true`.
|
||||||
|
|
||||||
|
## Workspace et dépendances
|
||||||
|
|
||||||
|
Nouveau membre :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Dépendances directes de la crate :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-core-lib
|
||||||
|
ksp-logging-lib
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
```
|
||||||
|
|
||||||
|
`reqwest` reste centralisé au workspace :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "^0.13", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `pre.001` avait vérifié le 2026-08-17 la génération `reqwest 0.13` et observé `0.13.4`. Cette tranche n'active volontairement aucune feature TLS/JSON/client : `reqwest` est utilisé uniquement pour `Url::parse` dans la validation de `HttpEndpointUrl`. Conformément à `RUST-DEP-001` / `RUST-DEP-003`, les features réseau et Tokio seront ajoutés seulement lorsque `pre.003` introduira un vrai client HTTP async.
|
||||||
|
|
||||||
|
Aucune dépendance directe vers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ksp-config-lib
|
||||||
|
ksp-store-api
|
||||||
|
ksp-store-lib
|
||||||
|
ksp-program-api
|
||||||
|
ksp-program-lib
|
||||||
|
tracing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contrats publics Transport
|
||||||
|
|
||||||
|
### Settings runtime
|
||||||
|
|
||||||
|
La crate expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
HttpTransportSettings
|
||||||
|
HttpEndpointSettings
|
||||||
|
HttpEndpointRoleSettings
|
||||||
|
HttpRoleLimits
|
||||||
|
HttpRetrySettings
|
||||||
|
HttpEndpointUrl
|
||||||
|
HttpProviderName
|
||||||
|
HttpClusterName
|
||||||
|
HttpRoleName
|
||||||
|
HttpRequestKind
|
||||||
|
```
|
||||||
|
|
||||||
|
Décisions :
|
||||||
|
|
||||||
|
- Transport reste totalement constructible/testable sans Config ;
|
||||||
|
- `provider`, `cluster`, `role` et `request_kind` sont des descriptors ouverts, pas des enums fermées ;
|
||||||
|
- `HttpEndpointUrl` accepte uniquement HTTP/HTTPS, conserve la valeur runtime réelle mais redacted systématiquement son `Debug` ;
|
||||||
|
- `HttpTransportSettings::validate()` vérifie notamment endpoints activés, identités/roles uniques, timeouts positifs, request kinds, wildcard, limites et backoff cohérents ;
|
||||||
|
- `NonZeroU32` empêche structurellement les zéros sur RPS/burst/concurrence lorsqu'ils sont configurés ;
|
||||||
|
- aucun accès direct à `.env` ou `std::env::var*` n'existe dans Transport.
|
||||||
|
|
||||||
|
### Codes d'erreur
|
||||||
|
|
||||||
|
Le domaine unique reste :
|
||||||
|
|
||||||
|
```text
|
||||||
|
onchain_transport
|
||||||
|
```
|
||||||
|
|
||||||
|
Codes stabilisés/réservés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
invalid_settings
|
||||||
|
endpoint_selection_failed
|
||||||
|
http_connection_failed
|
||||||
|
http_request_failed
|
||||||
|
timeout
|
||||||
|
rate_limited
|
||||||
|
json_encode_failed
|
||||||
|
json_decode_failed
|
||||||
|
json_rpc_protocol_invalid
|
||||||
|
rpc_application_error
|
||||||
|
method_removed
|
||||||
|
invalid_response
|
||||||
|
```
|
||||||
|
|
||||||
|
Ils utilisent exclusivement `ksp_core_lib::Error` / `ErrorCode`.
|
||||||
|
|
||||||
|
## JSON-RPC HTTP
|
||||||
|
|
||||||
|
La fondation expose :
|
||||||
|
|
||||||
|
```text
|
||||||
|
JsonRpcRequest
|
||||||
|
JsonRpcSuccessResponse
|
||||||
|
JsonRpcErrorObject
|
||||||
|
JsonRpcErrorResponse
|
||||||
|
JsonRpcResponse
|
||||||
|
parse_json_rpc_response_text
|
||||||
|
parse_json_rpc_response_value
|
||||||
|
```
|
||||||
|
|
||||||
|
Invariants :
|
||||||
|
|
||||||
|
- request ids KSP numériques `u64` ;
|
||||||
|
- `jsonrpc` exactement `"2.0"` ;
|
||||||
|
- id de réponse exactement égal à celui de la requête ;
|
||||||
|
- présence exclusive de `result` ou `error` ;
|
||||||
|
- JSON `null` conservé comme résultat valide ;
|
||||||
|
- erreur JSON syntaxique distincte d'une violation protocolaire ;
|
||||||
|
- application error RPC préservée dans `JsonRpcResponse::Error`, puis mappable vers `rpc_application_error` ;
|
||||||
|
- le mapping KSP ne copie pas le message/data distant dans le contexte générique ;
|
||||||
|
- `Debug` des requests masque les paramètres ; `Debug` des succès masque le résultat ; `Debug` des erreurs masque message/data distants.
|
||||||
|
|
||||||
|
Ce dernier point empêche un log/debug accidentel d'une transaction, d'un token provider ou d'une réponse massive tout en laissant les getters explicites accessibles au consumer légitime.
|
||||||
|
|
||||||
|
## Registre central des méthodes
|
||||||
|
|
||||||
|
`rpc_method.rs` matérialise exactement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
52 méthodes current
|
||||||
|
14 méthodes historiques Deprecated / Removed
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque descriptor possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
method
|
||||||
|
category
|
||||||
|
request_kind
|
||||||
|
documentation_status
|
||||||
|
runtime_status
|
||||||
|
request_form_status
|
||||||
|
operation_kind
|
||||||
|
transport_retry_class
|
||||||
|
replacement
|
||||||
|
coverage_release
|
||||||
|
```
|
||||||
|
|
||||||
|
La distribution de couverture est encodée et testée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1 : 4
|
||||||
|
0.2.2 : 22
|
||||||
|
0.2.3 : 11
|
||||||
|
0.2.4 : 15
|
||||||
|
----
|
||||||
|
52 current
|
||||||
|
```
|
||||||
|
|
||||||
|
Cas structurants déjà encodés :
|
||||||
|
|
||||||
|
```text
|
||||||
|
getTransaction -> StableWithDeprecatedLegacy
|
||||||
|
getBlock -> StableWithDeprecatedLegacy
|
||||||
|
sendTransaction -> WriteSubmission / NeverAfterDispatch
|
||||||
|
requestAirdrop -> WriteSubmission / NeverAfterDispatch
|
||||||
|
simulateTransaction -> Simulation / RetrySafe
|
||||||
|
14 historiques -> Deprecated / Removed / NotApplicable
|
||||||
|
```
|
||||||
|
|
||||||
|
`HttpRpcMethodDescriptor::ensure_runtime_supported()` centralise le comportement de statut :
|
||||||
|
|
||||||
|
- Stable + Supported : succès silencieux ;
|
||||||
|
- Deprecated/Unstable + Supported : `warn` via `ksp-logging-lib` ;
|
||||||
|
- Removed : `warn` puis erreur `method_removed`, sans simuler un appel supporté.
|
||||||
|
|
||||||
|
Le target utilisé est toujours le nom Cargo réel via `env!("CARGO_PKG_NAME")`; aucune dépendance directe `tracing` n'est introduite.
|
||||||
|
|
||||||
|
## Tests ajoutés
|
||||||
|
|
||||||
|
40 tests Rust sont définis :
|
||||||
|
|
||||||
|
- validation des settings et erreurs structurées ;
|
||||||
|
- HTTP/HTTPS uniquement ;
|
||||||
|
- redaction URL et `Debug` global settings ;
|
||||||
|
- endpoint enabled, doublons endpoint/rôle, wildcard, burst/RPS, timeouts, backoff ;
|
||||||
|
- sérialisation JSON-RPC request ;
|
||||||
|
- parse success/error/null ;
|
||||||
|
- id mismatch, version, `result xor error`, invalid JSON ;
|
||||||
|
- redaction des payloads request/result/error dans `Debug` ;
|
||||||
|
- mapping RPC application error ;
|
||||||
|
- registre 52 + 14 et unicité ;
|
||||||
|
- distribution 4/22/11/15 ;
|
||||||
|
- canaris exacts `getBalance/getGenesisHash/getHealth/getVersion` ;
|
||||||
|
- request forms deprecated de `getTransaction/getBlock` ;
|
||||||
|
- no-resend descriptors write ;
|
||||||
|
- warning path Unstable supporté ;
|
||||||
|
- erreur `method_removed` ;
|
||||||
|
- tests d'intégration de la façade publique ;
|
||||||
|
- canary de firewall des dépendances directes du manifest.
|
||||||
|
|
||||||
|
Les vrais tests unitaires sont physiquement sous `unit_tests/` et rattachés aux modules de production via `#[cfg(test)]` + `#[path = ...]`. `tests/` est réservé aux tests d'intégration publics.
|
||||||
|
|
||||||
|
## Fichiers ajoutés
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
|
crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/json_rpc.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/rpc_method.rs
|
||||||
|
crates/ksp-onchain-transport-lib/src/settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/json_rpc.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/rpc_method.rs
|
||||||
|
crates/ksp-onchain-transport-lib/unit_tests/settings.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/dependency_boundary.rs
|
||||||
|
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
|
deltas/0.2.1/pre.002.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cargo.toml
|
||||||
|
ROADMAP.md
|
||||||
|
docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers supprimés
|
||||||
|
|
||||||
|
Aucun.
|
||||||
|
|
||||||
|
## Validations exécutées dans l'environnement d'échange
|
||||||
|
|
||||||
|
Le conteneur ne fournit ni `cargo`, ni `rustc`, ni `rustfmt`. Les validations exécutables disponibles ont donc été limitées aux contrôles statiques suivants :
|
||||||
|
|
||||||
|
- parsing TOML du `Cargo.toml` racine et du manifest de la nouvelle crate ;
|
||||||
|
- contrôle `workspace.package.version = 0.2.1-pre.2` ;
|
||||||
|
- contrôle présence du nouveau membre workspace ;
|
||||||
|
- contrôle des headers `file:` et newline finale sur tous les nouveaux fichiers ;
|
||||||
|
- scan production : aucun `use`, `?`, `unwrap`, `expect`, `panic!`, `pub mod` ;
|
||||||
|
- scan manifest : aucune dépendance directe Config/Store/Program/`tracing` ;
|
||||||
|
- scan production : aucune lecture directe `std::env::var*` ;
|
||||||
|
- audit heuristique de rustdoc sur les éléments publics ;
|
||||||
|
- comparaison automatique du registre Rust avec la matrice `008` : 52 current identiques, dans le même ordre, et 14 historiques identiques ;
|
||||||
|
- contrôle de distribution de release : `4 / 22 / 11 / 15` ;
|
||||||
|
- contrôle des deux seuls marqueurs `StableWithDeprecatedLegacy` : `getTransaction`, `getBlock` ;
|
||||||
|
- contrôle des fences Markdown et headers des documents modifiés ;
|
||||||
|
- contrôle des lignes Rust > 160 colonnes après formatage manuel : aucune.
|
||||||
|
|
||||||
|
## Validations non exécutées
|
||||||
|
|
||||||
|
Obligatoires sur le checkout de développement avant commit :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all
|
||||||
|
cargo check --workspace
|
||||||
|
cargo clippy --workspace --all-targets
|
||||||
|
cargo test -p ksp-onchain-transport-lib
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
cargo tree -p ksp-onchain-transport-lib
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -d
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e features
|
||||||
|
cargo tree -p ksp-onchain-transport-lib -e normal
|
||||||
|
```
|
||||||
|
|
||||||
|
Les `cargo tree` sont désormais obligatoires parce que `reqwest` entre réellement dans le graphe à cette tranche. Les doublons significatifs doivent être examinés avant validation du commit.
|
||||||
|
|
||||||
|
Aucun build Tauri n'est requis : aucune application Tauri/frontend n'est modifiée.
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
- conserver un identifiant JSON-RPC KSP numérique `u64` pour les requêtes émises ;
|
||||||
|
- préserver les payloads JSON dynamiques via `serde_json::Value` sans introduire de DTO Solana SDK massif ;
|
||||||
|
- redacter les `Debug` wire susceptibles de contenir des secrets/payloads massifs ;
|
||||||
|
- encoder dès maintenant la couverture release et la retry class dans le registre central ;
|
||||||
|
- ne pas activer de features réseau `reqwest` ni Tokio avant leur premier usage réel ;
|
||||||
|
- ne pas introduire Config, Store, Program, Solana SDK haut niveau, base64, bs58, WebSocket ou gRPC.
|
||||||
|
|
||||||
|
## Questions ouvertes / reports explicites
|
||||||
|
|
||||||
|
Aucune question bloquante pour `pre.003`.
|
||||||
|
|
||||||
|
À décider avec l'implémentation réelle de `pre.003` :
|
||||||
|
|
||||||
|
- features `reqwest 0.13` exactes nécessaires au client async/TLS ;
|
||||||
|
- ownership précis du `reqwest::Client` par endpoint logique ;
|
||||||
|
- structures runtime du pool, fairness et snapshots ;
|
||||||
|
- première utilisation effective de Tokio et des primitives `sync` si nécessaires.
|
||||||
|
|
||||||
|
## Suite
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.1-pre.003
|
||||||
|
```
|
||||||
|
|
||||||
|
Mission prévue : endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs, sans encore ouvrir rate-limit/concurrence/retry effectif de `pre.004` au-delà des structures strictement nécessaires.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md -->
|
<!-- file: docs/plans/008-V0_2_1_ONCHAIN_HTTP_PLAN.md -->
|
||||||
<!-- version: 2 -->
|
<!-- version: 3 -->
|
||||||
|
|
||||||
# `0.2.1-pre.001` — plan `ksp-onchain-transport-lib` HTTP Solana foundation
|
# `0.2.1-pre.001` — plan `ksp-onchain-transport-lib` HTTP Solana foundation
|
||||||
|
|
||||||
@@ -696,18 +696,17 @@ Audit du 2026-08-17 :
|
|||||||
- `serde` / `serde_json` sont déjà des dépendances workspace `^1.0` ;
|
- `serde` / `serde_json` sont déjà des dépendances workspace `^1.0` ;
|
||||||
- bot3 utilisait également `base64` et `bs58`, à différer jusqu'au premier besoin d'encoding explicite.
|
- bot3 utilisait également `base64` et `bs58`, à différer jusqu'au premier besoin d'encoding explicite.
|
||||||
|
|
||||||
Décision de principe pour `pre.002` :
|
Décision concrétisée par `pre.002` :
|
||||||
|
|
||||||
```text
|
```text
|
||||||
reqwest = ^0.13, features minimales async/json/TLS à revérifier dans le manifest officiel avant ajout
|
reqwest = ^0.13, default-features = false au workspace
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true (+ feature sync si Semaphore retenu)
|
|
||||||
ksp-core-lib path/workspace
|
ksp-core-lib path/workspace
|
||||||
ksp-logging-lib path/workspace
|
ksp-logging-lib path/workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
`reqwest` `0.13` utilise un backend TLS rustls par défaut dans la génération actuelle ; la sélection exacte `default-features`/features doit être lue depuis le manifest/docs de la version réellement ajoutée, puis justifiée dans le delta.
|
`pre.002` utilise `reqwest` uniquement pour le parsing/validation robuste de `HttpEndpointUrl`; aucune requête réseau n'est encore créée. Conformément à `RUST-DEP-001` / `RUST-DEP-003`, aucune feature TLS/JSON/client ni dépendance Tokio locale n'est activée par anticipation. Les features réellement nécessaires au client async sont décidées et ajoutées dans `pre.003`, lorsque le chemin de compilation HTTP existe. Le même principe reporte `tokio` à la première tranche qui utilise effectivement ses primitives runtime/sync.
|
||||||
|
|
||||||
Ne pas ajouter :
|
Ne pas ajouter :
|
||||||
|
|
||||||
@@ -859,7 +858,7 @@ La release peut devenir stable seulement si :
|
|||||||
| Tranche | Objectif |
|
| Tranche | Objectif |
|
||||||
|-----------|----------------------------------------------------------------------------------------------------------------------|
|
|-----------|----------------------------------------------------------------------------------------------------------------------|
|
||||||
| `pre.001` | audit KSP + bot3 + docs officielles, matrice 52+14, architecture, split et sizing |
|
| `pre.001` | audit KSP + bot3 + docs officielles, matrice 52+14, architecture, split et sizing |
|
||||||
| `pre.002` | crate/workspace, codes erreur, settings/validation, JSON-RPC, descriptors/status, base Logging |
|
| `pre.002` | **réalisé** : crate/workspace, codes erreur, settings/validation, JSON-RPC, descriptors/status, base Logging |
|
||||||
| `pre.003` | endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs |
|
| `pre.003` | endpoint client + pool logique + rôles/capabilities + priorité/fairness/fallback + snapshots sûrs |
|
||||||
| `pre.004` | RPS/burst/concurrence/cooldown + timeout + retry/backoff + classification retry/no-resend |
|
| `pre.004` | RPS/burst/concurrence/cooldown + timeout + retry/backoff + classification retry/no-resend |
|
||||||
| `pre.005` | méthodes canari `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes |
|
| `pre.005` | méthodes canari `getHealth`, `getVersion`, `getGenesisHash`, `getBalance` + fixtures déterministes |
|
||||||
@@ -868,6 +867,23 @@ La release peut devenir stable seulement si :
|
|||||||
|
|
||||||
Ce découpage est révisable si une tranche dépasse le budget ; la release réduite, contrairement au scope initial, reste raisonnablement clôturable dans la session.
|
Ce découpage est révisable si une tranche dépasse le budget ; la release réduite, contrairement au scope initial, reste raisonnablement clôturable dans la session.
|
||||||
|
|
||||||
|
### 22.1 État après `0.2.1-pre.002`
|
||||||
|
|
||||||
|
`pre.002` matérialise la fondation sans ouvrir le client/pool de `pre.003` :
|
||||||
|
|
||||||
|
- nouvelle crate `ksp-onchain-transport-lib`, membre du workspace, `workspace.package.version = 0.2.1-pre.2` ;
|
||||||
|
- dépendances directes : `ksp-core-lib`, `ksp-logging-lib`, `serde`, `serde_json`, `reqwest` sans features par défaut ; aucune dépendance Config/Store/Program/`tracing` directe ;
|
||||||
|
- settings runtime publics : URL redacted, provider/cluster/role/request-kind ouverts, endpoints/rôles/limites/retry et validation structurelle ;
|
||||||
|
- douze codes d'erreur `onchain_transport` réservés/stabilisés selon la stratégie du §14 ;
|
||||||
|
- JSON-RPC HTTP KSP : request numérique KSP, sérialisation, parse success/error, validation `jsonrpc = 2.0`, id exact et exclusivité `result`/`error`, avec `null` résultat préservé ;
|
||||||
|
- `Debug` des requests/réponses n'expose pas les params, résultats ou payloads d'erreur distants par défaut ;
|
||||||
|
- registre central exact de 52 méthodes current + 14 historiques, avec catégorie, request kind, statut documentaire/runtime, forme legacy, operation kind, retry class, remplacement et release de couverture ;
|
||||||
|
- `getTransaction` et `getBlock` portent explicitement `StableWithDeprecatedLegacy`; `sendTransaction` et `requestAirdrop` portent `WriteSubmission / NeverAfterDispatch`;
|
||||||
|
- contrôle central `ensure_runtime_supported()` : warning KSP pour surfaces non stables supportées, warning + `method_removed` pour les historiques supprimées ;
|
||||||
|
- 40 tests Rust ajoutés (unitaires + intégration), dont redaction, invariants JSON-RPC, matrice 4/22/11/15 et canary de firewall du manifest.
|
||||||
|
|
||||||
|
Les éléments suivants restent volontairement à `pre.003+` : construction `reqwest::Client`, clients logiques, pool, sélection/rôles/fallback, Tokio runtime/sync, TLS, rate limiting, concurrence et retry effectif.
|
||||||
|
|
||||||
## 23. Séquence `0.2.x` recalibrée
|
## 23. Séquence `0.2.x` recalibrée
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
Reference in New Issue
Block a user