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::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    /// Whether this error is potentially transient and the operation could
21    /// be retried with backoff.
22    pub fn is_retryable(&self) -> bool {
23        self.status == 429 || self.status >= 500
24    }
25
26    /// Whether the HTTP status code was lost during error conversion.
27    ///
28    /// This happens when the server returns a non-JSON body (e.g. HTML from a
29    /// load balancer) that progenitor can't deserialize. The raw body is
30    /// preserved in `message`, but the status code is unavailable.
31    ///
32    /// Callers may want to treat these as retryable (usually transient proxy
33    /// errors) but should be aware that a 4xx with a non-JSON body would also
34    /// produce `status == 0`.
35    pub fn is_status_unknown(&self) -> bool {
36        self.status == 0
37    }
38}
39
40/// Errors that can occur when using the Trading SDK.
41#[non_exhaustive]
42#[derive(Error, Debug)]
43pub enum SDKError {
44    /// Invalid network configuration.
45    #[error("Invalid network connection specified")]
46    InvalidNetwork,
47
48    /// Invalid private key format or length.
49    #[error("Invalid private key: {0}")]
50    InvalidPrivateKey(String),
51
52    /// JSON serialization error.
53    #[error(transparent)]
54    JsonSerializeError(#[from] serde_json::Error),
55
56    /// HTTP client error.
57    #[error("HTTP error: {0}")]
58    HttpError(#[from] reqwest::Error),
59
60    /// Structured API error from the trading API.
61    #[error("API error: {0}")]
62    ApiError(ApiErrorResponse),
63
64    /// Client-side request error (not from the server).
65    #[error("Request error: {0}")]
66    RequestError(String),
67
68    /// No keypair available for signing.
69    #[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    // WebSocket errors
123    /// WebSocket connection error.
124    #[error("WebSocket connection error: {0}")]
125    WsConnectionError(String),
126
127    /// WebSocket upgrade error.
128    #[error(transparent)]
129    WsUpgradeError(#[from] reqwest_websocket::Error),
130
131    /// WebSocket connection was closed by the server.
132    #[error("WebSocket closed ({code}): {reason}")]
133    WsClosed {
134        /// Close code from the server
135        code: reqwest_websocket::CloseCode,
136        /// Close reason from the server
137        reason: String,
138    },
139
140    /// WebSocket stream ended unexpectedly without a close frame.
141    #[error("WebSocket stream ended unexpectedly")]
142    WsStreamEnded,
143
144    /// WebSocket connection handshake timed out.
145    #[error("WebSocket connection timed out waiting for server")]
146    WsConnectionTimeout,
147
148    /// WebSocket server did not send expected connected message.
149    #[error("Expected 'connected' status, got: {0}")]
150    WsHandshakeFailed(String),
151
152    /// WebSocket protocol error.
153    #[error("WebSocket error: {0}")]
154    WsError(String),
155
156    /// WebSocket server returned an error.
157    #[error("WebSocket server error (code {code}): {message}")]
158    WsServerError { code: i32, message: String },
159
160    /// JSON serialization error.
161    #[error(transparent)]
162    JsonError(#[from] serde_json::Error),
163}
164
165impl SDKError {
166    /// Whether this error is potentially transient and the operation could
167    /// be retried with backoff.
168    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    /// If this is an API error, returns the structured response.
183    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            // With 4XX/5XX ranges injected in build.rs, UnexpectedResponse only
201            // fires for truly exotic status codes (1xx, 3xx). Body can't be read
202            // synchronously so we only preserve the status code.
203            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            // Server returned 4XX/5XX but the body couldn't be deserialized as
212            // ApiErrorResponse (e.g., HTML from a load balancer, plain text, etc).
213            // Progenitor doesn't preserve the status code on this variant so we
214            // can't determine retryability. We surface the raw body as the message.
215            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            // Client-side errors (InvalidRequest, PreHookError) that aren't HTTP
224            // responses at all.
225            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        // Malformed body still becomes an ApiError with the raw body as message.
308        // Status code is lost (progenitor limitation) so status is 0.
309        let resp = err.api_error().expect("should be ApiError");
310        assert_eq!(resp.status, 0);
311        assert!(resp.message.contains("Bad Gateway"));
312        // status=0 means the status code was lost (progenitor limitation).
313        // is_retryable() returns false since we can't be sure it's a 5xx.
314        // Callers can use is_status_unknown() to decide for themselves.
315        assert!(!err.is_retryable());
316        assert!(resp.is_status_unknown());
317    }
318}