1pub(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#[derive(Debug, thiserror::Error)]
13pub enum WebSocketError {
14 #[error("Connection failed: {0}")]
15 ConnectionFailed(String),
17
18 #[error("Authentication failed: {0}")]
19 AuthenticationFailed(String),
21
22 #[error("Subscription failed: {0}")]
23 SubscriptionFailed(String),
25
26 #[error("Invalid message format: {0}")]
27 InvalidMessage(String),
29
30 #[error("Connection closed unexpectedly")]
31 ConnectionClosed,
33
34 #[error("Heartbeat timeout")]
35 HeartbeatTimeout,
37
38 #[error(
52 "API error {code}: {message}{}",
53 display::fmt_api_context(method, params)
54 )]
55 ApiError {
56 code: i64,
58 message: String,
60 method: Option<String>,
62 params: Option<Value>,
65 raw_response: Option<String>,
68 },
69
70 #[error("Operation timed out: {0}")]
71 Timeout(String),
73
74 #[error("Dispatcher task is not running")]
75 DispatcherDead,
78
79 #[error("Serialization error: {0}")]
80 Serialization(#[from] serde_json::Error),
86}
87
88impl WebSocketError {
89 #[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 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}