Skip to main content

deribit_websocket/error/
mod.rs

1//! Error handling module for WebSocket client
2
3pub(crate) mod display;
4pub(crate) mod envelope;
5pub(crate) mod redaction;
6
7use serde_json::Value;
8
9use crate::model::ws_types::{JsonRpcError, JsonRpcRequest};
10
11/// WebSocket-specific errors
12#[derive(Debug, thiserror::Error)]
13pub enum WebSocketError {
14    #[error("Connection failed: {0}")]
15    /// Connection failed with error message
16    ConnectionFailed(String),
17
18    #[error("Authentication failed: {0}")]
19    /// Authentication failed with error message
20    AuthenticationFailed(String),
21
22    #[error("Subscription failed: {0}")]
23    /// Subscription failed with error message
24    SubscriptionFailed(String),
25
26    #[error("Invalid message format: {0}")]
27    /// Invalid message format
28    InvalidMessage(String),
29
30    #[error("Connection closed unexpectedly")]
31    /// Connection was closed
32    ConnectionClosed,
33
34    #[error("Heartbeat timeout")]
35    /// Heartbeat timeout occurred
36    HeartbeatTimeout,
37
38    /// API error returned by Deribit, enriched with request + response
39    /// context for debugging.
40    ///
41    /// The base `Display` form is `"API error <code>: <message>"` so
42    /// callers that only call `.to_string()` see the legacy shape. When
43    /// `method` and/or `params` are present the suffix
44    /// `" (method=..., params=<truncated JSON>)"` is appended; `params`
45    /// is truncated to the first 512 characters.
46    ///
47    /// Sensitive keys (`access_token`, `refresh_token`, `client_secret`,
48    /// `signature`, `password`) inside `params` and `raw_response` are
49    /// recursively replaced with `"***"` at construction time, before
50    /// the value is stored — `Debug` output is therefore also safe.
51    #[error(
52        "API error {code}: {message}{}",
53        display::fmt_api_context(method, params)
54    )]
55    ApiError {
56        /// Deribit API error code.
57        code: i64,
58        /// Human-readable error message from the server.
59        message: String,
60        /// JSON-RPC method of the originating request, when available.
61        method: Option<String>,
62        /// Request parameters after sensitive-key redaction, when
63        /// available.
64        params: Option<Value>,
65        /// Server response JSON after sensitive-key redaction, when
66        /// available.
67        raw_response: Option<String>,
68    },
69
70    #[error("Operation timed out: {0}")]
71    /// Operation timed out (e.g., `send_request` awaiting a matching response)
72    Timeout(String),
73
74    #[error("Dispatcher task is not running")]
75    /// The background dispatcher task is not running (never started, shut
76    /// down, or panicked). No further I/O can be performed through it.
77    DispatcherDead,
78
79    #[error("Serialization error: {0}")]
80    /// JSON serialization or deserialization failed.
81    ///
82    /// Typically raised when a request contains a numeric field whose value
83    /// cannot be represented in JSON (e.g. `NaN` or `Infinity` in an `f64`),
84    /// or when parsing a malformed response payload.
85    Serialization(#[from] serde_json::Error),
86}
87
88impl WebSocketError {
89    /// Construct an enriched `ApiError` from the originating request and
90    /// the server-side error payload.
91    ///
92    /// Applies recursive, case-insensitive redaction of the sensitive
93    /// keys `access_token`, `refresh_token`, `client_secret`,
94    /// `signature`, and `password` to both `params` (cloned from
95    /// `request`) and the caller-supplied `raw_response` before storing
96    /// them, so the returned value is safe to log or surface through
97    /// `Display` / `Debug`.
98    #[must_use]
99    pub fn api_error_from_parts(
100        request: &JsonRpcRequest,
101        error: JsonRpcError,
102        raw_response: Option<String>,
103    ) -> Self {
104        Self::ApiError {
105            code: i64::from(error.code),
106            message: error.message,
107            method: Some(request.method.clone()),
108            params: request.params.clone().map(redaction::redact_params),
109            raw_response: raw_response.map(|r| redaction::redact_raw_response(&r)),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use serde_json::json;
118
119    fn make_request(method: &str, params: Option<Value>) -> JsonRpcRequest {
120        JsonRpcRequest {
121            jsonrpc: "2.0".to_owned(),
122            id: json!(1),
123            method: method.to_owned(),
124            params,
125        }
126    }
127
128    fn make_rpc_error(code: i32, message: &str) -> JsonRpcError {
129        JsonRpcError {
130            code,
131            message: message.to_owned(),
132            data: None,
133        }
134    }
135
136    #[test]
137    fn api_error_display_without_context_matches_legacy_prefix() {
138        let err = WebSocketError::ApiError {
139            code: 10_000,
140            message: "not_allowed".to_owned(),
141            method: None,
142            params: None,
143            raw_response: None,
144        };
145        assert_eq!(err.to_string(), "API error 10000: not_allowed");
146    }
147
148    #[test]
149    fn api_error_display_includes_method_when_present() {
150        let request = make_request("public/get_time", None);
151        let rpc_err = make_rpc_error(11_050, "bad_arguments");
152        let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
153        let text = err.to_string();
154        assert!(text.contains("API error 11050: bad_arguments"));
155        assert!(text.contains("method=public/get_time"));
156    }
157
158    #[test]
159    fn api_error_display_includes_truncated_params() {
160        let big_string = "a".repeat(5_000);
161        let params = json!({ "blob": big_string });
162        let request = make_request("private/buy", Some(params));
163        let rpc_err = make_rpc_error(10_001, "invalid_params");
164        let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
165        let text = err.to_string();
166        assert!(text.contains("method=private/buy"));
167        assert!(text.contains("params="));
168        assert!(
169            text.chars().count() < 1_024,
170            "Display should be truncated, got {} chars",
171            text.chars().count()
172        );
173    }
174
175    #[test]
176    fn api_error_from_parts_redacts_access_token_in_display() {
177        let request = make_request("public/auth", Some(json!({ "access_token": "leaky" })));
178        let rpc_err = make_rpc_error(13_004, "invalid_credentials");
179        let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
180        let text = err.to_string();
181        assert!(!text.contains("leaky"), "access_token leaked: {text}");
182        assert!(text.contains("***"));
183    }
184
185    #[test]
186    fn api_error_from_parts_redacts_refresh_token_in_display() {
187        let request = make_request(
188            "public/auth",
189            Some(json!({ "refresh_token": "refresh-leak" })),
190        );
191        let err =
192            WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
193        assert!(!err.to_string().contains("refresh-leak"));
194    }
195
196    #[test]
197    fn api_error_from_parts_redacts_client_secret_in_display() {
198        let request = make_request(
199            "public/auth",
200            Some(json!({ "client_secret": "client-secret-leak" })),
201        );
202        let err =
203            WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
204        assert!(!err.to_string().contains("client-secret-leak"));
205    }
206
207    #[test]
208    fn api_error_from_parts_redacts_signature_in_display() {
209        let request = make_request("public/auth", Some(json!({ "signature": "sig-leak" })));
210        let err =
211            WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
212        assert!(!err.to_string().contains("sig-leak"));
213    }
214
215    #[test]
216    fn api_error_from_parts_redacts_password_in_display() {
217        let request = make_request("public/auth", Some(json!({ "password": "pw-leak" })));
218        let err =
219            WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
220        assert!(!err.to_string().contains("pw-leak"));
221    }
222
223    #[test]
224    fn api_error_from_parts_redacts_all_keys_in_debug() {
225        let request = make_request(
226            "public/auth",
227            Some(json!({
228                "access_token": "a-leak",
229                "refresh_token": "r-leak",
230                "client_secret": "c-leak",
231                "signature": "s-leak",
232                "password": "p-leak",
233            })),
234        );
235        let err =
236            WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
237        let debug = format!("{err:?}");
238        for leak in ["a-leak", "r-leak", "c-leak", "s-leak", "p-leak"] {
239            assert!(
240                !debug.contains(leak),
241                "{leak} leaked in Debug output: {debug}"
242            );
243        }
244    }
245
246    #[test]
247    fn api_error_from_parts_redacts_nested_sensitive_keys() {
248        let request = make_request(
249            "private/xyz",
250            Some(json!({
251                "outer": {
252                    "inner": {
253                        "password": "deep-leak"
254                    }
255                }
256            })),
257        );
258        let err =
259            WebSocketError::api_error_from_parts(&request, make_rpc_error(10_001, "err"), None);
260        let debug = format!("{err:?}");
261        assert!(!debug.contains("deep-leak"));
262    }
263
264    #[test]
265    fn api_error_from_parts_redacts_case_insensitive_in_debug() {
266        // Only letter-case varies here; the spec keys (snake_case) must
267        // still be recognisable by `eq_ignore_ascii_case`.
268        let request = make_request(
269            "private/xyz",
270            Some(json!({
271                "Password": "UPPER-leak",
272                "Access_Token": "upper-snake-leak",
273                "REFRESH_TOKEN": "shouty-leak",
274            })),
275        );
276        let err =
277            WebSocketError::api_error_from_parts(&request, make_rpc_error(10_001, "err"), None);
278        let debug = format!("{err:?}");
279        assert!(!debug.contains("UPPER-leak"));
280        assert!(!debug.contains("upper-snake-leak"));
281        assert!(!debug.contains("shouty-leak"));
282    }
283
284    #[test]
285    fn api_error_from_parts_sets_method_from_request() {
286        let request = make_request("public/test", None);
287        let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(1, "x"), None);
288        match err {
289            WebSocketError::ApiError { method, .. } => {
290                assert_eq!(method.as_deref(), Some("public/test"));
291            }
292            other => panic!("expected ApiError, got {other:?}"),
293        }
294    }
295
296    #[test]
297    fn api_error_from_parts_handles_none_params() {
298        let request = make_request("public/test", None);
299        let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(1, "x"), None);
300        match err {
301            WebSocketError::ApiError { params, .. } => assert!(params.is_none()),
302            other => panic!("expected ApiError, got {other:?}"),
303        }
304    }
305
306    #[test]
307    fn api_error_from_parts_preserves_error_code_and_message() {
308        let request = make_request("public/test", None);
309        let err = WebSocketError::api_error_from_parts(
310            &request,
311            make_rpc_error(13_004, "invalid_credentials"),
312            None,
313        );
314        match err {
315            WebSocketError::ApiError { code, message, .. } => {
316                assert_eq!(code, 13_004);
317                assert_eq!(message, "invalid_credentials");
318            }
319            other => panic!("expected ApiError, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn api_error_from_parts_redacts_raw_response() {
325        let request = make_request("public/auth", None);
326        let raw =
327            r#"{"id":1,"error":{"code":13004,"message":"x","data":{"access_token":"raw-leak"}}}"#;
328        let err = WebSocketError::api_error_from_parts(
329            &request,
330            make_rpc_error(13_004, "x"),
331            Some(raw.to_owned()),
332        );
333        match err {
334            WebSocketError::ApiError {
335                raw_response: Some(stored),
336                ..
337            } => {
338                assert!(!stored.contains("raw-leak"));
339                assert!(stored.contains("***"));
340            }
341            other => panic!("expected ApiError with raw_response, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn api_error_matches_on_code_still_works() {
347        let request = make_request("public/test", None);
348        let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(42, "oops"), None);
349        match err {
350            WebSocketError::ApiError { code, message, .. } => {
351                assert_eq!(code, 42);
352                assert_eq!(message, "oops");
353            }
354            _ => panic!("expected ApiError"),
355        }
356    }
357}