pub(crate) mod display;
pub(crate) mod envelope;
pub(crate) mod redaction;
use serde_json::Value;
use crate::model::ws_types::{JsonRpcError, JsonRpcRequest};
#[derive(Debug, thiserror::Error)]
pub enum WebSocketError {
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Subscription failed: {0}")]
SubscriptionFailed(String),
#[error("Invalid message format: {0}")]
InvalidMessage(String),
#[error("Connection closed unexpectedly")]
ConnectionClosed,
#[error("Heartbeat timeout")]
HeartbeatTimeout,
#[error(
"API error {code}: {message}{}",
display::fmt_api_context(method, params)
)]
ApiError {
code: i64,
message: String,
method: Option<String>,
params: Option<Value>,
raw_response: Option<String>,
},
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("Dispatcher task is not running")]
DispatcherDead,
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
impl WebSocketError {
#[must_use]
pub fn api_error_from_parts(
request: &JsonRpcRequest,
error: JsonRpcError,
raw_response: Option<String>,
) -> Self {
Self::ApiError {
code: i64::from(error.code),
message: error.message,
method: Some(request.method.clone()),
params: request.params.clone().map(redaction::redact_params),
raw_response: raw_response.map(|r| redaction::redact_raw_response(&r)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_request(method: &str, params: Option<Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_owned(),
id: json!(1),
method: method.to_owned(),
params,
}
}
fn make_rpc_error(code: i32, message: &str) -> JsonRpcError {
JsonRpcError {
code,
message: message.to_owned(),
data: None,
}
}
#[test]
fn api_error_display_without_context_matches_legacy_prefix() {
let err = WebSocketError::ApiError {
code: 10_000,
message: "not_allowed".to_owned(),
method: None,
params: None,
raw_response: None,
};
assert_eq!(err.to_string(), "API error 10000: not_allowed");
}
#[test]
fn api_error_display_includes_method_when_present() {
let request = make_request("public/get_time", None);
let rpc_err = make_rpc_error(11_050, "bad_arguments");
let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
let text = err.to_string();
assert!(text.contains("API error 11050: bad_arguments"));
assert!(text.contains("method=public/get_time"));
}
#[test]
fn api_error_display_includes_truncated_params() {
let big_string = "a".repeat(5_000);
let params = json!({ "blob": big_string });
let request = make_request("private/buy", Some(params));
let rpc_err = make_rpc_error(10_001, "invalid_params");
let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
let text = err.to_string();
assert!(text.contains("method=private/buy"));
assert!(text.contains("params="));
assert!(
text.chars().count() < 1_024,
"Display should be truncated, got {} chars",
text.chars().count()
);
}
#[test]
fn api_error_from_parts_redacts_access_token_in_display() {
let request = make_request("public/auth", Some(json!({ "access_token": "leaky" })));
let rpc_err = make_rpc_error(13_004, "invalid_credentials");
let err = WebSocketError::api_error_from_parts(&request, rpc_err, None);
let text = err.to_string();
assert!(!text.contains("leaky"), "access_token leaked: {text}");
assert!(text.contains("***"));
}
#[test]
fn api_error_from_parts_redacts_refresh_token_in_display() {
let request = make_request(
"public/auth",
Some(json!({ "refresh_token": "refresh-leak" })),
);
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
assert!(!err.to_string().contains("refresh-leak"));
}
#[test]
fn api_error_from_parts_redacts_client_secret_in_display() {
let request = make_request(
"public/auth",
Some(json!({ "client_secret": "client-secret-leak" })),
);
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
assert!(!err.to_string().contains("client-secret-leak"));
}
#[test]
fn api_error_from_parts_redacts_signature_in_display() {
let request = make_request("public/auth", Some(json!({ "signature": "sig-leak" })));
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
assert!(!err.to_string().contains("sig-leak"));
}
#[test]
fn api_error_from_parts_redacts_password_in_display() {
let request = make_request("public/auth", Some(json!({ "password": "pw-leak" })));
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
assert!(!err.to_string().contains("pw-leak"));
}
#[test]
fn api_error_from_parts_redacts_all_keys_in_debug() {
let request = make_request(
"public/auth",
Some(json!({
"access_token": "a-leak",
"refresh_token": "r-leak",
"client_secret": "c-leak",
"signature": "s-leak",
"password": "p-leak",
})),
);
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(13_004, "err"), None);
let debug = format!("{err:?}");
for leak in ["a-leak", "r-leak", "c-leak", "s-leak", "p-leak"] {
assert!(
!debug.contains(leak),
"{leak} leaked in Debug output: {debug}"
);
}
}
#[test]
fn api_error_from_parts_redacts_nested_sensitive_keys() {
let request = make_request(
"private/xyz",
Some(json!({
"outer": {
"inner": {
"password": "deep-leak"
}
}
})),
);
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(10_001, "err"), None);
let debug = format!("{err:?}");
assert!(!debug.contains("deep-leak"));
}
#[test]
fn api_error_from_parts_redacts_case_insensitive_in_debug() {
let request = make_request(
"private/xyz",
Some(json!({
"Password": "UPPER-leak",
"Access_Token": "upper-snake-leak",
"REFRESH_TOKEN": "shouty-leak",
})),
);
let err =
WebSocketError::api_error_from_parts(&request, make_rpc_error(10_001, "err"), None);
let debug = format!("{err:?}");
assert!(!debug.contains("UPPER-leak"));
assert!(!debug.contains("upper-snake-leak"));
assert!(!debug.contains("shouty-leak"));
}
#[test]
fn api_error_from_parts_sets_method_from_request() {
let request = make_request("public/test", None);
let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(1, "x"), None);
match err {
WebSocketError::ApiError { method, .. } => {
assert_eq!(method.as_deref(), Some("public/test"));
}
other => panic!("expected ApiError, got {other:?}"),
}
}
#[test]
fn api_error_from_parts_handles_none_params() {
let request = make_request("public/test", None);
let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(1, "x"), None);
match err {
WebSocketError::ApiError { params, .. } => assert!(params.is_none()),
other => panic!("expected ApiError, got {other:?}"),
}
}
#[test]
fn api_error_from_parts_preserves_error_code_and_message() {
let request = make_request("public/test", None);
let err = WebSocketError::api_error_from_parts(
&request,
make_rpc_error(13_004, "invalid_credentials"),
None,
);
match err {
WebSocketError::ApiError { code, message, .. } => {
assert_eq!(code, 13_004);
assert_eq!(message, "invalid_credentials");
}
other => panic!("expected ApiError, got {other:?}"),
}
}
#[test]
fn api_error_from_parts_redacts_raw_response() {
let request = make_request("public/auth", None);
let raw =
r#"{"id":1,"error":{"code":13004,"message":"x","data":{"access_token":"raw-leak"}}}"#;
let err = WebSocketError::api_error_from_parts(
&request,
make_rpc_error(13_004, "x"),
Some(raw.to_owned()),
);
match err {
WebSocketError::ApiError {
raw_response: Some(stored),
..
} => {
assert!(!stored.contains("raw-leak"));
assert!(stored.contains("***"));
}
other => panic!("expected ApiError with raw_response, got {other:?}"),
}
}
#[test]
fn api_error_matches_on_code_still_works() {
let request = make_request("public/test", None);
let err = WebSocketError::api_error_from_parts(&request, make_rpc_error(42, "oops"), None);
match err {
WebSocketError::ApiError { code, message, .. } => {
assert_eq!(code, 42);
assert_eq!(message, "oops");
}
_ => panic!("expected ApiError"),
}
}
}