Skip to main content

bullet_rust_sdk/
errors.rs

1//! Error types for the Trading SDK.
2
3use std::string::FromUtf8Error;
4
5use thiserror::Error;
6
7use crate::generated::types::{ApiErrorDetail, ApiErrorResponse};
8
9/// Render each variant for human consumption.
10///
11/// `JsonValidationErrorDetail` surfaces its `rule` + `message` inline — what
12/// you actually want to see in a log line. The catch-all `Object` variant
13/// is free-form upstream data we don't try to interpret, so it falls back
14/// to a compact JSON dump.
15impl 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    /// Whether this error is potentially transient and the operation could
42    /// be retried with backoff.
43    pub fn is_retryable(&self) -> bool {
44        self.status == 429 || self.status >= 500
45    }
46
47    /// Whether the HTTP status code was lost during error conversion.
48    ///
49    /// This happens when the server returns a non-JSON body (e.g. HTML from a
50    /// load balancer) that progenitor can't deserialize. The raw body is
51    /// preserved in `message`, but the status code is unavailable.
52    ///
53    /// Callers may want to treat these as retryable (usually transient proxy
54    /// errors) but should be aware that a 4xx with a non-JSON body would also
55    /// produce `status == 0`.
56    pub fn is_status_unknown(&self) -> bool {
57        self.status == 0
58    }
59}
60
61/// Errors that can occur when using the Trading SDK.
62#[non_exhaustive]
63#[derive(Error, Debug)]
64pub enum SDKError {
65    /// Invalid network configuration.
66    #[error("Invalid network connection specified")]
67    InvalidNetwork,
68
69    /// Invalid private key format or length.
70    #[error("Invalid private key: {0}")]
71    InvalidPrivateKey(String),
72
73    /// JSON serialization error.
74    #[error(transparent)]
75    JsonSerializeError(#[from] serde_json::Error),
76
77    /// HTTP client error.
78    #[error("HTTP error: {0}")]
79    HttpError(#[from] reqwest::Error),
80
81    /// Structured API error from the trading API.
82    ///
83    /// Boxed because `ApiErrorResponse` carries optional details + error_id
84    /// strings and a nested enum, making the variant large enough to bloat
85    /// every `SDKResult<T>` on the stack (clippy::result_large_err). Boxing
86    /// pushes the cost to error-construction time (negligible — happy paths
87    /// don't allocate) and keeps Result types cheap to pass around.
88    #[error("API error: {0}")]
89    ApiError(Box<ApiErrorResponse>),
90
91    /// Client-side request error (not from the server).
92    #[error("Request error: {0}")]
93    RequestError(String),
94
95    /// No keypair available for signing.
96    #[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    // WebSocket errors
150    /// WebSocket connection error.
151    #[error("WebSocket connection error: {0}")]
152    WsConnectionError(String),
153
154    /// WebSocket upgrade error.
155    #[error(transparent)]
156    WsUpgradeError(#[from] reqwest_websocket::Error),
157
158    /// WebSocket connection was closed by the server.
159    #[error("WebSocket closed ({code}): {reason}")]
160    WsClosed {
161        /// Close code from the server
162        code: reqwest_websocket::CloseCode,
163        /// Close reason from the server
164        reason: String,
165    },
166
167    /// WebSocket stream ended unexpectedly without a close frame.
168    #[error("WebSocket stream ended unexpectedly")]
169    WsStreamEnded,
170
171    /// WebSocket connection handshake timed out.
172    #[error("WebSocket connection timed out waiting for server")]
173    WsConnectionTimeout,
174
175    /// WebSocket server did not send expected connected message.
176    #[error("Expected 'connected' status, got: {0}")]
177    WsHandshakeFailed(String),
178
179    /// WebSocket protocol error.
180    #[error("WebSocket error: {0}")]
181    WsError(String),
182
183    /// WebSocket server returned an error.
184    #[error("WebSocket server error (code {code}): {message}")]
185    WsServerError { code: i32, message: String },
186
187    /// JSON serialization error.
188    #[error(transparent)]
189    JsonError(#[from] serde_json::Error),
190}
191
192impl SDKError {
193    /// Whether this error is potentially transient and the operation could
194    /// be retried with backoff.
195    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    /// If this is an API error, returns the structured response.
210    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            // With 4XX/5XX ranges injected in build.rs, UnexpectedResponse only
230            // fires for truly exotic status codes (1xx, 3xx). Body can't be read
231            // synchronously so we only preserve the status code.
232            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            // Server returned 4XX/5XX but the body couldn't be deserialized as
242            // ApiErrorResponse (e.g., HTML from a load balancer, plain text, etc).
243            // Progenitor doesn't preserve the status code on this variant so we
244            // can't determine retryability. We surface the raw body as the message.
245            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            // Client-side errors (InvalidRequest, PreHookError) that aren't HTTP
255            // responses at all.
256            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        // Malformed body still becomes an ApiError with the raw body as message.
346        // Status code is lost (progenitor limitation) so status is 0.
347        let resp = err.api_error().expect("should be ApiError");
348        assert_eq!(resp.status, 0);
349        assert!(resp.message.contains("Bad Gateway"));
350        // status=0 means the status code was lost (progenitor limitation).
351        // is_retryable() returns false since we can't be sure it's a 5xx.
352        // Callers can use is_status_unknown() to decide for themselves.
353        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}