1use std::string::FromUtf8Error;
4
5use thiserror::Error;
6
7use crate::generated::types::{ApiErrorDetail, ApiErrorResponse};
8
9impl std::fmt::Display for ApiErrorDetail {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 Self::JsonValidationErrorDetail(d) => write!(f, "{}: {}", d.rule, d.message),
19 Self::Object(map) => match serde_json::to_string(map) {
20 Ok(s) => f.write_str(&s),
21 Err(_) => write!(f, "{map:?}"),
22 },
23 }
24 }
25}
26
27impl std::fmt::Display for ApiErrorResponse {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 write!(f, "HTTP {}: {}", self.status, self.message)?;
30 if let Some(details) = &self.details {
31 write!(f, " ({details})")?;
32 }
33 if let Some(error_id) = &self.error_id {
34 write!(f, " [error_id={error_id}]")?;
35 }
36 Ok(())
37 }
38}
39
40impl ApiErrorResponse {
41 pub fn is_retryable(&self) -> bool {
44 self.status == 429 || self.status >= 500
45 }
46
47 pub fn is_status_unknown(&self) -> bool {
57 self.status == 0
58 }
59}
60
61#[non_exhaustive]
63#[derive(Error, Debug)]
64pub enum SDKError {
65 #[error("Invalid network connection specified")]
67 InvalidNetwork,
68
69 #[error("Invalid private key: {0}")]
71 InvalidPrivateKey(String),
72
73 #[error(transparent)]
75 JsonSerializeError(#[from] serde_json::Error),
76
77 #[error("HTTP error: {0}")]
79 HttpError(#[from] reqwest::Error),
80
81 #[error("API error: {0}")]
89 ApiError(Box<ApiErrorResponse>),
90
91 #[error("Request error: {0}")]
93 RequestError(String),
94
95 #[error(
97 "No keypair available. Provide a signer via Transaction::builder().signer() or Client::builder().keypair()"
98 )]
99 MissingKeypair,
100
101 #[error(transparent)]
102 StringParseError(#[from] FromUtf8Error),
103
104 #[error("Failed to read chain_id {0}")]
105 ChainIdCastError(std::num::TryFromIntError),
106
107 #[error("Provided URL was neither websocket or rest url")]
108 InvalidNetworkUrl,
109
110 #[error("Invalid schema response: missing or invalid '{0}' field")]
111 InvalidSchemaResponse(&'static str),
112
113 #[error("Invalid chain hash: {0}")]
114 InvalidChainHash(String),
115
116 #[error("Transaction serialization failed: {0}")]
117 SerializationError(String),
118
119 #[error("System time error: clock is before UNIX epoch")]
120 SystemTimeError,
121
122 #[error("Invalid signature length: expected 64 bytes, got {0}")]
123 InvalidSignatureLength(usize),
124
125 #[error("Invalid public key length: expected 32 bytes, got {0}")]
126 InvalidPublicKeyLength(usize),
127
128 #[error("Schema outdated - recompile the binary to update bullet-exchange-interface")]
129 SchemaOutdated,
130
131 #[error("CallMessage {0} must be added to user-actions")]
132 UnsupportedCallMessage(String),
133
134 #[error("Transaction is outdated - need to re-sign again.")]
135 TransactionOutdated,
136
137 #[error(transparent)]
138 WebsocketError(#[from] Box<WSErrors>),
139}
140
141impl From<WSErrors> for SDKError {
142 fn from(err: WSErrors) -> Self {
143 SDKError::WebsocketError(Box::new(err))
144 }
145}
146
147#[derive(Debug, Error)]
148pub enum WSErrors {
149 #[error("WebSocket connection error: {0}")]
152 WsConnectionError(String),
153
154 #[error(transparent)]
156 WsUpgradeError(#[from] reqwest_websocket::Error),
157
158 #[error("WebSocket closed ({code}): {reason}")]
160 WsClosed {
161 code: reqwest_websocket::CloseCode,
163 reason: String,
165 },
166
167 #[error("WebSocket stream ended unexpectedly")]
169 WsStreamEnded,
170
171 #[error("WebSocket connection timed out waiting for server")]
173 WsConnectionTimeout,
174
175 #[error("Expected 'connected' status, got: {0}")]
177 WsHandshakeFailed(String),
178
179 #[error("WebSocket error: {0}")]
181 WsError(String),
182
183 #[error("WebSocket server error (code {code}): {message}")]
185 WsServerError { code: i32, message: String },
186
187 #[error(transparent)]
189 JsonError(#[from] serde_json::Error),
190}
191
192impl SDKError {
193 pub fn is_retryable(&self) -> bool {
196 match self {
197 SDKError::HttpError(e) => e.is_timeout() || e.is_request(),
198 SDKError::ApiError(resp) => resp.is_retryable(),
199 SDKError::WebsocketError(e) => matches!(
200 e.as_ref(),
201 WSErrors::WsConnectionError(_)
202 | WSErrors::WsStreamEnded
203 | WSErrors::WsConnectionTimeout
204 ),
205 _ => false,
206 }
207 }
208
209 pub fn api_error(&self) -> Option<&ApiErrorResponse> {
211 match self {
212 SDKError::ApiError(resp) => Some(resp.as_ref()),
213 _ => None,
214 }
215 }
216}
217
218pub type SDKResult<T, E = SDKError> = Result<T, E>;
219
220impl From<progenitor_client::Error<ApiErrorResponse>> for SDKError {
221 fn from(err: progenitor_client::Error<ApiErrorResponse>) -> Self {
222 match err {
223 progenitor_client::Error::ErrorResponse(resp) => {
224 SDKError::ApiError(Box::new(resp.into_inner()))
225 }
226 progenitor_client::Error::CommunicationError(e) => SDKError::HttpError(e),
227 progenitor_client::Error::ResponseBodyError(e) => SDKError::HttpError(e),
228 progenitor_client::Error::InvalidUpgrade(e) => SDKError::HttpError(e),
229 progenitor_client::Error::UnexpectedResponse(resp) => {
233 let status = resp.status().as_u16();
234 SDKError::ApiError(Box::new(ApiErrorResponse {
235 status,
236 message: format!("HTTP {status}"),
237 details: None,
238 error_id: None,
239 }))
240 }
241 progenitor_client::Error::InvalidResponsePayload(bytes, _) => {
246 let body = String::from_utf8_lossy(&bytes);
247 SDKError::ApiError(Box::new(ApiErrorResponse {
248 status: 0,
249 message: body.into_owned(),
250 details: None,
251 error_id: None,
252 }))
253 }
254 other => SDKError::RequestError(format!("{other}")),
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use wiremock::matchers::{method, path};
264 use wiremock::{Mock, MockServer, ResponseTemplate};
265
266 use super::*;
267
268 async fn mock_submit_tx(status: u16, body: serde_json::Value) -> (MockServer, SDKError) {
269 let server = MockServer::start().await;
270
271 Mock::given(method("POST"))
272 .and(path("/tx/submit"))
273 .respond_with(ResponseTemplate::new(status).set_body_json(&body))
274 .mount(&server)
275 .await;
276
277 let client = crate::generated::Client::new(&server.uri());
278 let result = client
279 .submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
280 .await;
281
282 (server, result.unwrap_err().into())
283 }
284
285 #[tokio::test]
286 async fn error_response_is_structured() {
287 use crate::generated::types::ApiErrorDetail;
288
289 let (_server, err) = mock_submit_tx(
290 400,
291 serde_json::json!({
292 "status": 400,
293 "message": "Transaction validation failed: insufficient funds",
294 "details": {"reason": "insufficient_balance"}
295 }),
296 )
297 .await;
298
299 let resp = err.api_error().expect("should be ApiError");
300 assert_eq!(resp.status, 400);
301 assert_eq!(resp.message, "Transaction validation failed: insufficient funds");
302 match resp.details.as_ref().expect("details present") {
303 ApiErrorDetail::Object(map) => {
304 assert_eq!(map["reason"], "insufficient_balance");
305 }
306 other => panic!("expected Object variant, got {other:?}"),
307 }
308 assert!(!err.is_retryable());
309 assert!(err.to_string().contains("insufficient funds"));
310 }
311
312 #[tokio::test]
313 async fn error_response_5xx_is_retryable() {
314 let (_server, err) = mock_submit_tx(
315 503,
316 serde_json::json!({
317 "status": 503,
318 "message": "Service unavailable"
319 }),
320 )
321 .await;
322
323 assert!(err.is_retryable());
324 assert_eq!(err.api_error().unwrap().status, 503);
325 }
326
327 #[tokio::test]
328 async fn error_response_malformed_body_preserves_raw_text() {
329 let server = MockServer::start().await;
330
331 Mock::given(method("POST"))
332 .and(path("/tx/submit"))
333 .respond_with(
334 ResponseTemplate::new(502).set_body_string("<html><body>Bad Gateway</body></html>"),
335 )
336 .mount(&server)
337 .await;
338
339 let client = crate::generated::Client::new(&server.uri());
340 let result = client
341 .submit_tx(&crate::generated::types::SubmitTxRequest { body: "dGVzdA==".into() })
342 .await;
343
344 let err: SDKError = result.unwrap_err().into();
345 let resp = err.api_error().expect("should be ApiError");
348 assert_eq!(resp.status, 0);
349 assert!(resp.message.contains("Bad Gateway"));
350 assert!(!err.is_retryable());
354 assert!(resp.is_status_unknown());
355 }
356
357 #[tokio::test]
358 async fn error_response_surfaces_error_id() {
359 let (_server, err) = mock_submit_tx(
360 400,
361 serde_json::json!({
362 "status": 400,
363 "message": "Transaction validation failed",
364 "error_id": "8b2e4d9f-7a1c-4f0e-9c5d-3e6a8b1c2d4f"
365 }),
366 )
367 .await;
368
369 let resp = err.api_error().expect("should be ApiError");
370 assert_eq!(resp.error_id.as_deref(), Some("8b2e4d9f-7a1c-4f0e-9c5d-3e6a8b1c2d4f"));
371 assert!(err.to_string().contains("8b2e4d9f-7a1c-4f0e-9c5d-3e6a8b1c2d4f"));
372 }
373
374 #[tokio::test]
375 async fn error_response_parses_json_validation_detail() {
376 use crate::generated::types::ApiErrorDetail;
377
378 let (_server, err) = mock_submit_tx(
379 400,
380 serde_json::json!({
381 "status": 400,
382 "message": "Invalid request payload",
383 "details": {
384 "rule": "wrong_type",
385 "message": "invalid type: integer, expected a string at line 4 column 30"
386 }
387 }),
388 )
389 .await;
390
391 let resp = err.api_error().expect("should be ApiError");
392 match resp.details.as_ref().expect("details present") {
393 ApiErrorDetail::JsonValidationErrorDetail(d) => {
394 assert_eq!(d.rule, "wrong_type");
395 assert!(d.message.contains("expected a string"));
396 }
397 other => panic!("expected JsonValidationErrorDetail variant, got {other:?}"),
398 }
399 }
400}