nautilus_coinbase/http/
error.rs1use nautilus_network::http::{HttpClientError, ReqwestError};
17use thiserror::Error;
18
19#[derive(Debug, Error)]
21pub enum Error {
22 #[error("transport error: {0}")]
24 Transport(String),
25
26 #[error("serde error: {0}")]
28 Serde(#[from] serde_json::Error),
29
30 #[error("auth error: {0}")]
32 Auth(String),
33
34 #[error("Rate limited (retry_after_ms={retry_after_ms:?})")]
36 RateLimit { retry_after_ms: Option<u64> },
37
38 #[error("bad request: {0}")]
40 BadRequest(String),
41
42 #[error("exchange error: {0}")]
44 Exchange(String),
45
46 #[error("timeout")]
48 Timeout,
49
50 #[error("decode error: {0}")]
52 Decode(String),
53
54 #[error("HTTP error {status}: {message}")]
56 Http { status: u16, message: String },
57
58 #[error("URL parse error: {0}")]
60 UrlParse(#[from] url::ParseError),
61
62 #[error("IO error: {0}")]
64 Io(#[from] std::io::Error),
65}
66
67impl Error {
68 pub fn transport(msg: impl Into<String>) -> Self {
70 Self::Transport(msg.into())
71 }
72
73 pub fn auth(msg: impl Into<String>) -> Self {
75 Self::Auth(msg.into())
76 }
77
78 pub fn rate_limit(retry_after_ms: Option<u64>) -> Self {
80 Self::RateLimit { retry_after_ms }
81 }
82
83 pub fn bad_request(msg: impl Into<String>) -> Self {
85 Self::BadRequest(msg.into())
86 }
87
88 pub fn exchange(msg: impl Into<String>) -> Self {
90 Self::Exchange(msg.into())
91 }
92
93 pub fn decode(msg: impl Into<String>) -> Self {
95 Self::Decode(msg.into())
96 }
97
98 pub fn http(status: u16, message: impl Into<String>) -> Self {
100 Self::Http {
101 status,
102 message: message.into(),
103 }
104 }
105
106 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 #[expect(clippy::needless_pass_by_value)]
120 pub fn from_reqwest(error: ReqwestError) -> Self {
121 if error.is_timeout() {
122 Self::Timeout
123 } else if let Some(status) = error.status() {
124 let status_code = status.as_u16();
125 match status_code {
126 401 | 403 => Self::auth(format!("HTTP {status_code}: authentication failed")),
127 400 => Self::bad_request(format!("HTTP {status_code}: bad request")),
128 429 => Self::rate_limit(None),
129 500..=599 => Self::exchange(format!("HTTP {status_code}: server error")),
130 _ => Self::http(status_code, format!("HTTP error: {error}")),
131 }
132 } else if error.is_connect() || error.is_request() {
133 Self::transport(format!("Request error: {error}"))
134 } else {
135 Self::transport(format!("Unknown reqwest error: {error}"))
136 }
137 }
138
139 #[expect(clippy::needless_pass_by_value)]
141 pub fn from_http_client(error: HttpClientError) -> Self {
142 Self::transport(format!("HTTP client error: {error}"))
143 }
144
145 pub fn is_retryable(&self) -> bool {
147 match self {
148 Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } | Self::Exchange(_) => true,
149 Self::Http { status, .. } => *status >= 500,
150 _ => false,
151 }
152 }
153
154 pub fn is_rate_limited(&self) -> bool {
156 matches!(self, Self::RateLimit { .. })
157 }
158
159 pub fn is_auth_error(&self) -> bool {
161 matches!(self, Self::Auth(_))
162 }
163}
164
165pub type Result<T> = std::result::Result<T, Error>;
167
168#[cfg(test)]
169mod tests {
170 use rstest::rstest;
171
172 use super::*;
173
174 #[rstest]
175 fn test_error_constructors() {
176 let transport_err = Error::transport("Connection failed");
177 assert!(matches!(transport_err, Error::Transport(_)));
178 assert_eq!(
179 transport_err.to_string(),
180 "transport error: Connection failed"
181 );
182
183 let auth_err = Error::auth("Invalid JWT");
184 assert!(auth_err.is_auth_error());
185
186 let rate_limit_err = Error::rate_limit(Some(30000));
187 assert!(rate_limit_err.is_rate_limited());
188 assert!(rate_limit_err.is_retryable());
189
190 let http_err = Error::http(500, "Internal server error");
191 assert!(http_err.is_retryable());
192 }
193
194 #[rstest]
195 fn test_retryable_errors() {
196 assert!(Error::transport("test").is_retryable());
197 assert!(Error::Timeout.is_retryable());
198 assert!(Error::rate_limit(None).is_retryable());
199 assert!(Error::http(500, "server error").is_retryable());
200 assert!(Error::exchange("server error").is_retryable());
201
202 assert!(!Error::auth("test").is_retryable());
203 assert!(!Error::bad_request("test").is_retryable());
204 assert!(!Error::decode("test").is_retryable());
205 }
206
207 #[rstest]
208 #[case(401, true, false, false)]
209 #[case(403, true, false, false)]
210 #[case(400, false, false, false)]
211 #[case(429, false, true, true)]
212 #[case(500, false, false, true)]
213 #[case(503, false, false, true)]
214 #[case(404, false, false, false)]
215 fn test_from_http_status_classification(
216 #[case] status: u16,
217 #[case] expect_auth: bool,
218 #[case] expect_rate_limit: bool,
219 #[case] expect_retryable: bool,
220 ) {
221 let err = Error::from_http_status(status, b"test body");
222 assert_eq!(err.is_auth_error(), expect_auth, "is_auth for {status}");
223 assert_eq!(
224 err.is_rate_limited(),
225 expect_rate_limit,
226 "is_rate_limited for {status}"
227 );
228 assert_eq!(
229 err.is_retryable(),
230 expect_retryable,
231 "is_retryable for {status}"
232 );
233 }
234
235 #[rstest]
236 fn test_error_display() {
237 let err = Error::RateLimit {
238 retry_after_ms: Some(60000),
239 };
240 assert_eq!(err.to_string(), "Rate limited (retry_after_ms=Some(60000))");
241 }
242}