bullet_rust_sdk/
errors.rs1use std::string::FromUtf8Error;
4
5use thiserror::Error;
6
7use crate::generated::types::ApiErrorResponse;
8
9impl std::fmt::Display for ApiErrorResponse {
10 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11 write!(f, "HTTP {}: {}", self.status, self.message)?;
12 if let Some(details) = &self.details {
13 write!(f, " ({details})")?;
14 }
15 Ok(())
16 }
17}
18
19impl ApiErrorResponse {
20 pub fn is_retryable(&self) -> bool {
23 self.status == 429 || self.status >= 500
24 }
25
26 pub fn is_status_unknown(&self) -> bool {
36 self.status == 0
37 }
38}
39
40#[non_exhaustive]
42#[derive(Error, Debug)]
43pub enum SDKError {
44 #[error("Invalid network connection specified")]
46 InvalidNetwork,
47
48 #[error("Invalid private key: {0}")]
50 InvalidPrivateKey(String),
51
52 #[error(transparent)]
54 JsonSerializeError(#[from] serde_json::Error),
55
56 #[error("HTTP error: {0}")]
58 HttpError(#[from] reqwest::Error),
59
60 #[error("API error: {0}")]
62 ApiError(ApiErrorResponse),
63
64 #[error("Request error: {0}")]
66 RequestError(String),
67
68 #[error(
70 "No keypair available. Provide a signer via Transaction::builder().signer() or Client::builder().keypair()"
71 )]
72 MissingKeypair,
73
74 #[error(transparent)]
75 StringParseError(#[from] FromUtf8Error),
76
77 #[error("Failed to read chain_id {0}")]
78 ChainIdCastError(std::num::TryFromIntError),
79
80 #[error("Provided URL was neither websocket or rest url")]
81 InvalidNetworkUrl,
82
83 #[error("Invalid schema response: missing or invalid '{0}' field")]
84 InvalidSchemaResponse(&'static str),
85
86 #[error("Invalid chain hash: {0}")]
87 InvalidChainHash(String),
88
89 #[error("Transaction serialization failed: {0}")]
90 SerializationError(String),
91
92 #[error("System time error: clock is before UNIX epoch")]
93 SystemTimeError,
94
95 #[error("Invalid signature length: expected 64 bytes, got {0}")]
96 InvalidSignatureLength(usize),
97
98 #[error("Invalid public key length: expected 32 bytes, got {0}")]
99 InvalidPublicKeyLength(usize),
100
101 #[error("Schema outdated - recompile the binary to update bullet-exchange-interface")]
102 SchemaOutdated,
103
104 #[error("CallMessage {0} must be added to user-actions")]
105 UnsupportedCallMessage(String),
106
107 #[error("Transaction is outdated - need to re-sign again.")]
108 TransactionOutdated,
109
110 #[error(transparent)]
111 WebsocketError(#[from] Box<WSErrors>),
112}
113
114impl From<WSErrors> for SDKError {
115 fn from(err: WSErrors) -> Self {
116 SDKError::WebsocketError(Box::new(err))
117 }
118}
119
120#[derive(Debug, Error)]
121pub enum WSErrors {
122 #[error("WebSocket connection error: {0}")]
125 WsConnectionError(String),
126
127 #[error(transparent)]
129 WsUpgradeError(#[from] reqwest_websocket::Error),
130
131 #[error("WebSocket closed ({code}): {reason}")]
133 WsClosed {
134 code: reqwest_websocket::CloseCode,
136 reason: String,
138 },
139
140 #[error("WebSocket stream ended unexpectedly")]
142 WsStreamEnded,
143
144 #[error("WebSocket connection timed out waiting for server")]
146 WsConnectionTimeout,
147
148 #[error("Expected 'connected' status, got: {0}")]
150 WsHandshakeFailed(String),
151
152 #[error("WebSocket error: {0}")]
154 WsError(String),
155
156 #[error("WebSocket server error (code {code}): {message}")]
158 WsServerError { code: i32, message: String },
159
160 #[error(transparent)]
162 JsonError(#[from] serde_json::Error),
163}
164
165impl SDKError {
166 pub fn is_retryable(&self) -> bool {
169 match self {
170 SDKError::HttpError(e) => e.is_timeout() || e.is_request(),
171 SDKError::ApiError(resp) => resp.is_retryable(),
172 SDKError::WebsocketError(e) => matches!(
173 e.as_ref(),
174 WSErrors::WsConnectionError(_)
175 | WSErrors::WsStreamEnded
176 | WSErrors::WsConnectionTimeout
177 ),
178 _ => false,
179 }
180 }
181
182 pub fn api_error(&self) -> Option<&ApiErrorResponse> {
184 match self {
185 SDKError::ApiError(resp) => Some(resp),
186 _ => None,
187 }
188 }
189}
190
191pub type SDKResult<T, E = SDKError> = Result<T, E>;
192
193impl From<progenitor_client::Error<ApiErrorResponse>> for SDKError {
194 fn from(err: progenitor_client::Error<ApiErrorResponse>) -> Self {
195 match err {
196 progenitor_client::Error::ErrorResponse(resp) => SDKError::ApiError(resp.into_inner()),
197 progenitor_client::Error::CommunicationError(e) => SDKError::HttpError(e),
198 progenitor_client::Error::ResponseBodyError(e) => SDKError::HttpError(e),
199 progenitor_client::Error::InvalidUpgrade(e) => SDKError::HttpError(e),
200 progenitor_client::Error::UnexpectedResponse(resp) => {
204 let status = resp.status().as_u16();
205 SDKError::ApiError(ApiErrorResponse {
206 status,
207 message: format!("HTTP {status}"),
208 details: None,
209 })
210 }
211 progenitor_client::Error::InvalidResponsePayload(bytes, _) => {
216 let body = String::from_utf8_lossy(&bytes);
217 SDKError::ApiError(ApiErrorResponse {
218 status: 0,
219 message: body.into_owned(),
220 details: None,
221 })
222 }
223 other => SDKError::RequestError(format!("{other}")),
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use wiremock::matchers::{method, path};
233 use wiremock::{Mock, MockServer, ResponseTemplate};
234
235 use super::*;
236
237 async fn mock_submit_tx(status: u16, body: serde_json::Value) -> (MockServer, SDKError) {
238 let server = MockServer::start().await;
239
240 Mock::given(method("POST"))
241 .and(path("/tx/submit"))
242 .respond_with(ResponseTemplate::new(status).set_body_json(&body))
243 .mount(&server)
244 .await;
245
246 let client = crate::generated::Client::new(&server.uri());
247 let result = client
248 .submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
249 .await;
250
251 (server, result.unwrap_err().into())
252 }
253
254 #[tokio::test]
255 async fn error_response_is_structured() {
256 let (_server, err) = mock_submit_tx(
257 400,
258 serde_json::json!({
259 "status": 400,
260 "message": "Transaction validation failed: insufficient funds",
261 "details": {"reason": "insufficient_balance"}
262 }),
263 )
264 .await;
265
266 let resp = err.api_error().expect("should be ApiError");
267 assert_eq!(resp.status, 400);
268 assert_eq!(resp.message, "Transaction validation failed: insufficient funds");
269 assert_eq!(resp.details.as_ref().unwrap()["reason"], "insufficient_balance");
270 assert!(!err.is_retryable());
271 assert!(err.to_string().contains("insufficient funds"));
272 }
273
274 #[tokio::test]
275 async fn error_response_5xx_is_retryable() {
276 let (_server, err) = mock_submit_tx(
277 503,
278 serde_json::json!({
279 "status": 503,
280 "message": "Service unavailable"
281 }),
282 )
283 .await;
284
285 assert!(err.is_retryable());
286 assert_eq!(err.api_error().unwrap().status, 503);
287 }
288
289 #[tokio::test]
290 async fn error_response_malformed_body_preserves_raw_text() {
291 let server = MockServer::start().await;
292
293 Mock::given(method("POST"))
294 .and(path("/tx/submit"))
295 .respond_with(
296 ResponseTemplate::new(502).set_body_string("<html><body>Bad Gateway</body></html>"),
297 )
298 .mount(&server)
299 .await;
300
301 let client = crate::generated::Client::new(&server.uri());
302 let result = client
303 .submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
304 .await;
305
306 let err: SDKError = result.unwrap_err().into();
307 let resp = err.api_error().expect("should be ApiError");
310 assert_eq!(resp.status, 0);
311 assert!(resp.message.contains("Bad Gateway"));
312 assert!(!err.is_retryable());
316 assert!(resp.is_status_unknown());
317 }
318}