Skip to main content

nautilus_coinbase/http/
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
16use nautilus_network::http::HttpClientError;
17use thiserror::Error;
18
19/// Error type for Coinbase operations.
20#[derive(Debug, Error)]
21pub enum Error {
22    /// Transport layer errors (network, connection issues).
23    #[error("transport error: {0}")]
24    Transport(String),
25
26    /// JSON serialization/deserialization errors.
27    #[error("serde error: {0}")]
28    Serde(#[from] serde_json::Error),
29
30    /// Authentication errors (invalid key, expired JWT).
31    #[error("auth error: {0}")]
32    Auth(String),
33
34    /// Rate limiting errors.
35    #[error("Rate limited (retry_after_ms={retry_after_ms:?})")]
36    RateLimit { retry_after_ms: Option<u64> },
37
38    /// Bad request errors (client-side invalid payload).
39    #[error("bad request: {0}")]
40    BadRequest(String),
41
42    /// Exchange-specific errors from Coinbase server.
43    #[error("exchange error: {0}")]
44    Exchange(String),
45
46    /// Request timeout.
47    #[error("timeout")]
48    Timeout,
49
50    /// Message decoding/parsing errors.
51    #[error("decode error: {0}")]
52    Decode(String),
53
54    /// HTTP errors with status code.
55    #[error("HTTP error {status}: {message}")]
56    Http { status: u16, message: String },
57
58    /// URL parsing errors.
59    #[error("URL parse error: {0}")]
60    UrlParse(#[from] url::ParseError),
61
62    /// Standard IO errors.
63    #[error("IO error: {0}")]
64    Io(#[from] std::io::Error),
65}
66
67impl Error {
68    /// Creates a transport error.
69    pub fn transport(msg: impl Into<String>) -> Self {
70        Self::Transport(msg.into())
71    }
72
73    /// Creates an auth error.
74    pub fn auth(msg: impl Into<String>) -> Self {
75        Self::Auth(msg.into())
76    }
77
78    /// Creates a rate limit error.
79    pub fn rate_limit(retry_after_ms: Option<u64>) -> Self {
80        Self::RateLimit { retry_after_ms }
81    }
82
83    /// Creates a bad request error.
84    pub fn bad_request(msg: impl Into<String>) -> Self {
85        Self::BadRequest(msg.into())
86    }
87
88    /// Creates an exchange error.
89    pub fn exchange(msg: impl Into<String>) -> Self {
90        Self::Exchange(msg.into())
91    }
92
93    /// Creates a decode error.
94    pub fn decode(msg: impl Into<String>) -> Self {
95        Self::Decode(msg.into())
96    }
97
98    /// Creates an HTTP error.
99    pub fn http(status: u16, message: impl Into<String>) -> Self {
100        Self::Http {
101            status,
102            message: message.into(),
103        }
104    }
105
106    /// Creates an error from HTTP status code and body.
107    pub fn from_http_status(status: u16, body: &[u8]) -> Self {
108        let message = String::from_utf8_lossy(body).to_string();
109        match status {
110            401 | 403 => Self::auth(format!("HTTP {status}: {message}")),
111            400 => Self::bad_request(format!("HTTP {status}: {message}")),
112            429 => Self::rate_limit(None),
113            500..=599 => Self::exchange(format!("HTTP {status}: {message}")),
114            _ => Self::http(status, message),
115        }
116    }
117
118    /// Maps HTTP client errors to appropriate error types.
119    #[expect(clippy::needless_pass_by_value)]
120    pub fn from_http_client(error: HttpClientError) -> Self {
121        Self::transport(format!("HTTP client error: {error}"))
122    }
123
124    /// Returns true if the error is retryable.
125    pub fn is_retryable(&self) -> bool {
126        match self {
127            Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } | Self::Exchange(_) => true,
128            Self::Http { status, .. } => *status >= 500,
129            _ => false,
130        }
131    }
132
133    /// Returns true if the error is due to rate limiting.
134    pub fn is_rate_limited(&self) -> bool {
135        matches!(self, Self::RateLimit { .. })
136    }
137
138    /// Returns true if the error is due to authentication.
139    pub fn is_auth_error(&self) -> bool {
140        matches!(self, Self::Auth(_))
141    }
142}
143
144/// Result type alias for Coinbase operations.
145pub type Result<T> = std::result::Result<T, Error>;
146
147#[cfg(test)]
148mod tests {
149    use rstest::rstest;
150
151    use super::*;
152
153    #[rstest]
154    fn test_error_constructors() {
155        let transport_err = Error::transport("Connection failed");
156        assert!(matches!(transport_err, Error::Transport(_)));
157        assert_eq!(
158            transport_err.to_string(),
159            "transport error: Connection failed"
160        );
161
162        let auth_err = Error::auth("Invalid JWT");
163        assert!(auth_err.is_auth_error());
164
165        let rate_limit_err = Error::rate_limit(Some(30000));
166        assert!(rate_limit_err.is_rate_limited());
167        assert!(rate_limit_err.is_retryable());
168
169        let http_err = Error::http(500, "Internal server error");
170        assert!(http_err.is_retryable());
171    }
172
173    #[rstest]
174    fn test_retryable_errors() {
175        assert!(Error::transport("test").is_retryable());
176        assert!(Error::Timeout.is_retryable());
177        assert!(Error::rate_limit(None).is_retryable());
178        assert!(Error::http(500, "server error").is_retryable());
179        assert!(Error::exchange("server error").is_retryable());
180
181        assert!(!Error::auth("test").is_retryable());
182        assert!(!Error::bad_request("test").is_retryable());
183        assert!(!Error::decode("test").is_retryable());
184    }
185
186    #[rstest]
187    #[case(401, true, false, false)]
188    #[case(403, true, false, false)]
189    #[case(400, false, false, false)]
190    #[case(429, false, true, true)]
191    #[case(500, false, false, true)]
192    #[case(503, false, false, true)]
193    #[case(404, false, false, false)]
194    fn test_from_http_status_classification(
195        #[case] status: u16,
196        #[case] expect_auth: bool,
197        #[case] expect_rate_limit: bool,
198        #[case] expect_retryable: bool,
199    ) {
200        let err = Error::from_http_status(status, b"test body");
201        assert_eq!(err.is_auth_error(), expect_auth, "is_auth for {status}");
202        assert_eq!(
203            err.is_rate_limited(),
204            expect_rate_limit,
205            "is_rate_limited for {status}"
206        );
207        assert_eq!(
208            err.is_retryable(),
209            expect_retryable,
210            "is_retryable for {status}"
211        );
212    }
213
214    #[rstest]
215    fn test_error_display() {
216        let err = Error::RateLimit {
217            retry_after_ms: Some(60000),
218        };
219        assert_eq!(err.to_string(), "Rate limited (retry_after_ms=Some(60000))");
220    }
221}