Skip to main content

nautilus_derive/websocket/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Error types for the Derive WebSocket client.
17
18use std::time::Duration;
19
20use serde_json::Value;
21use thiserror::Error;
22
23use crate::signing::auth::AuthError;
24
25/// Result alias for WebSocket operations.
26pub type Result<T> = std::result::Result<T, DeriveWsError>;
27
28/// Errors raised by the Derive WebSocket client.
29#[derive(Debug, Error)]
30pub enum DeriveWsError {
31    /// Transport-level failure (handshake, send, broken pipe).
32    #[error("transport error: {0}")]
33    Transport(String),
34
35    /// JSON (de)serialization failed for an outbound or inbound frame.
36    #[error("serde error: {0}")]
37    Serde(#[from] serde_json::Error),
38
39    /// JSON-RPC error envelope returned by the venue.
40    #[error("JSON-RPC error {code}: {message}")]
41    JsonRpc {
42        /// Venue-defined error code.
43        code: i64,
44        /// Human-readable error message.
45        message: String,
46        /// Optional structured diagnostic payload.
47        data: Option<Value>,
48    },
49
50    /// Local pacing rejects a reservation before the request is transmitted.
51    #[error("pacing reservation expired before dispatch, retry after {retry_after:?}")]
52    RateLimited {
53        /// Remaining wait until local capacity becomes available.
54        retry_after: Duration,
55    },
56
57    /// Awaited request response was not delivered (handler dropped the sender).
58    #[error("request `{method}` cancelled before response was received")]
59    RequestCancelled {
60        /// Method that was awaiting a response.
61        method: String,
62    },
63
64    /// No response arrived within the configured request timeout. The request
65    /// was sent, so the outcome of a state-changing write is unknown.
66    #[error("request `{method}` timed out before response was received")]
67    Timeout {
68        /// Method that was awaiting a response.
69        method: String,
70    },
71
72    /// Auth header construction failed (e.g. clock skew, signer error).
73    #[error("auth error: {0}")]
74    Auth(#[from] AuthError),
75
76    /// The WebSocket session did not authenticate before a request could proceed.
77    #[error("authentication failed for `{operation}`: {reason}")]
78    Authentication {
79        /// Operation attempting or awaiting authentication.
80        operation: String,
81        /// Authentication failure detail.
82        reason: String,
83    },
84
85    /// One or more channels were rejected in a subscribe result.
86    #[error("subscription failed: {details}")]
87    Subscription {
88        /// Per-channel failure detail returned by the venue.
89        details: String,
90    },
91
92    /// Private operation invoked without credentials configured on the client.
93    #[error("missing credentials for `{operation}`")]
94    MissingCredentials {
95        /// Operation that requires authentication.
96        operation: String,
97    },
98
99    /// Client used before `connect()` completed.
100    #[error("WebSocket client is not connected")]
101    NotConnected,
102}
103
104impl DeriveWsError {
105    /// Constructs a [`DeriveWsError::Transport`] error.
106    #[must_use]
107    pub fn transport(msg: impl Into<String>) -> Self {
108        Self::Transport(msg.into())
109    }
110
111    pub(crate) fn redacted_message(&self) -> String {
112        match self {
113            Self::JsonRpc { code, .. } => format!("JSON-RPC error {code}"),
114            Self::RateLimited { retry_after } => {
115                format!("rate limited, retry after {retry_after:?}")
116            }
117            Self::Transport(_) => "transport error".to_string(),
118            Self::Serde(_) => "JSON error".to_string(),
119            Self::RequestCancelled { .. } => "request cancelled".to_string(),
120            Self::Timeout { .. } => "request timed out".to_string(),
121            Self::Auth(_) | Self::Authentication { .. } => "authentication error".to_string(),
122            Self::Subscription { .. } => "subscription failed".to_string(),
123            Self::MissingCredentials { .. } => "missing credentials".to_string(),
124            Self::NotConnected => "not connected".to_string(),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use rstest::rstest;
132    use serde_json::json;
133
134    use super::*;
135
136    #[rstest]
137    fn test_transport_constructor_carries_message() {
138        let err = DeriveWsError::transport("broken pipe");
139        assert!(err.to_string().contains("broken pipe"));
140    }
141
142    #[rstest]
143    fn test_jsonrpc_error_renders_code_and_message() {
144        let err = DeriveWsError::JsonRpc {
145            code: -32601,
146            message: "Method not found".to_string(),
147            data: Some(json!({"method": "public/foo"})),
148        };
149
150        let rendered = err.to_string();
151        assert!(rendered.contains("-32601"));
152        assert!(rendered.contains("Method not found"));
153    }
154
155    #[rstest]
156    fn test_missing_credentials_names_operation() {
157        let err = DeriveWsError::MissingCredentials {
158            operation: "public/login".to_string(),
159        };
160
161        assert!(err.to_string().contains("public/login"));
162    }
163
164    #[rstest]
165    fn test_authentication_error_names_operation_and_reason() {
166        let err = DeriveWsError::Authentication {
167            operation: "private/order".to_string(),
168            reason: "session recovery failed".to_string(),
169        };
170
171        assert!(err.to_string().contains("private/order"));
172        assert!(err.to_string().contains("session recovery failed"));
173    }
174}