use std::string::FromUtf8Error;
use thiserror::Error;
use crate::generated::types::ApiErrorResponse;
impl std::fmt::Display for ApiErrorResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HTTP {}: {}", self.status, self.message)?;
if let Some(details) = &self.details {
write!(f, " ({details})")?;
}
Ok(())
}
}
impl ApiErrorResponse {
pub fn is_retryable(&self) -> bool {
self.status == 429 || self.status >= 500
}
pub fn is_status_unknown(&self) -> bool {
self.status == 0
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum SDKError {
#[error("Invalid network connection specified")]
InvalidNetwork,
#[error("Invalid private key: {0}")]
InvalidPrivateKey(String),
#[error(transparent)]
JsonSerializeError(#[from] serde_json::Error),
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("API error: {0}")]
ApiError(ApiErrorResponse),
#[error("Request error: {0}")]
RequestError(String),
#[error(
"No keypair available. Provide a signer via Transaction::builder().signer() or Client::builder().keypair()"
)]
MissingKeypair,
#[error(transparent)]
StringParseError(#[from] FromUtf8Error),
#[error("Failed to read chain_id {0}")]
ChainIdCastError(std::num::TryFromIntError),
#[error("Provided URL was neither websocket or rest url")]
InvalidNetworkUrl,
#[error("Invalid schema response: missing or invalid '{0}' field")]
InvalidSchemaResponse(&'static str),
#[error("Invalid chain hash: {0}")]
InvalidChainHash(String),
#[error("Transaction serialization failed: {0}")]
SerializationError(String),
#[error("System time error: clock is before UNIX epoch")]
SystemTimeError,
#[error("Invalid signature length: expected 64 bytes, got {0}")]
InvalidSignatureLength(usize),
#[error("Invalid public key length: expected 32 bytes, got {0}")]
InvalidPublicKeyLength(usize),
#[error("Schema outdated - recompile the binary to update bullet-exchange-interface")]
SchemaOutdated,
#[error("CallMessage {0} must be added to user-actions")]
UnsupportedCallMessage(String),
#[error("Transaction is outdated - need to re-sign again.")]
TransactionOutdated,
#[error(transparent)]
WebsocketError(#[from] Box<WSErrors>),
}
impl From<WSErrors> for SDKError {
fn from(err: WSErrors) -> Self {
SDKError::WebsocketError(Box::new(err))
}
}
#[derive(Debug, Error)]
pub enum WSErrors {
#[error("WebSocket connection error: {0}")]
WsConnectionError(String),
#[error(transparent)]
WsUpgradeError(#[from] reqwest_websocket::Error),
#[error("WebSocket closed ({code}): {reason}")]
WsClosed {
code: reqwest_websocket::CloseCode,
reason: String,
},
#[error("WebSocket stream ended unexpectedly")]
WsStreamEnded,
#[error("WebSocket connection timed out waiting for server")]
WsConnectionTimeout,
#[error("Expected 'connected' status, got: {0}")]
WsHandshakeFailed(String),
#[error("WebSocket error: {0}")]
WsError(String),
#[error("WebSocket server error (code {code}): {message}")]
WsServerError { code: i32, message: String },
#[error(transparent)]
JsonError(#[from] serde_json::Error),
}
impl SDKError {
pub fn is_retryable(&self) -> bool {
match self {
SDKError::HttpError(e) => e.is_timeout() || e.is_request(),
SDKError::ApiError(resp) => resp.is_retryable(),
SDKError::WebsocketError(e) => matches!(
e.as_ref(),
WSErrors::WsConnectionError(_)
| WSErrors::WsStreamEnded
| WSErrors::WsConnectionTimeout
),
_ => false,
}
}
pub fn api_error(&self) -> Option<&ApiErrorResponse> {
match self {
SDKError::ApiError(resp) => Some(resp),
_ => None,
}
}
}
pub type SDKResult<T, E = SDKError> = Result<T, E>;
impl From<progenitor_client::Error<ApiErrorResponse>> for SDKError {
fn from(err: progenitor_client::Error<ApiErrorResponse>) -> Self {
match err {
progenitor_client::Error::ErrorResponse(resp) => SDKError::ApiError(resp.into_inner()),
progenitor_client::Error::CommunicationError(e) => SDKError::HttpError(e),
progenitor_client::Error::ResponseBodyError(e) => SDKError::HttpError(e),
progenitor_client::Error::InvalidUpgrade(e) => SDKError::HttpError(e),
progenitor_client::Error::UnexpectedResponse(resp) => {
let status = resp.status().as_u16();
SDKError::ApiError(ApiErrorResponse {
status,
message: format!("HTTP {status}"),
details: None,
})
}
progenitor_client::Error::InvalidResponsePayload(bytes, _) => {
let body = String::from_utf8_lossy(&bytes);
SDKError::ApiError(ApiErrorResponse {
status: 0,
message: body.into_owned(),
details: None,
})
}
other => SDKError::RequestError(format!("{other}")),
}
}
}
#[cfg(test)]
mod tests {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
async fn mock_submit_tx(status: u16, body: serde_json::Value) -> (MockServer, SDKError) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/tx/submit"))
.respond_with(ResponseTemplate::new(status).set_body_json(&body))
.mount(&server)
.await;
let client = crate::generated::Client::new(&server.uri());
let result = client
.submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
.await;
(server, result.unwrap_err().into())
}
#[tokio::test]
async fn error_response_is_structured() {
let (_server, err) = mock_submit_tx(
400,
serde_json::json!({
"status": 400,
"message": "Transaction validation failed: insufficient funds",
"details": {"reason": "insufficient_balance"}
}),
)
.await;
let resp = err.api_error().expect("should be ApiError");
assert_eq!(resp.status, 400);
assert_eq!(resp.message, "Transaction validation failed: insufficient funds");
assert_eq!(resp.details.as_ref().unwrap()["reason"], "insufficient_balance");
assert!(!err.is_retryable());
assert!(err.to_string().contains("insufficient funds"));
}
#[tokio::test]
async fn error_response_5xx_is_retryable() {
let (_server, err) = mock_submit_tx(
503,
serde_json::json!({
"status": 503,
"message": "Service unavailable"
}),
)
.await;
assert!(err.is_retryable());
assert_eq!(err.api_error().unwrap().status, 503);
}
#[tokio::test]
async fn error_response_malformed_body_preserves_raw_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/tx/submit"))
.respond_with(
ResponseTemplate::new(502).set_body_string("<html><body>Bad Gateway</body></html>"),
)
.mount(&server)
.await;
let client = crate::generated::Client::new(&server.uri());
let result = client
.submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
.await;
let err: SDKError = result.unwrap_err().into();
let resp = err.api_error().expect("should be ApiError");
assert_eq!(resp.status, 0);
assert!(resp.message.contains("Bad Gateway"));
assert!(!err.is_retryable());
assert!(resp.is_status_unknown());
}
}