nautilus_coinbase/http/
error.rs1use nautilus_network::http::HttpClientError;
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_http_client(error: HttpClientError) -> Self {
121 Self::transport(format!("HTTP client error: {error}"))
122 }
123
124 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 pub fn is_rate_limited(&self) -> bool {
135 matches!(self, Self::RateLimit { .. })
136 }
137
138 pub fn is_auth_error(&self) -> bool {
140 matches!(self, Self::Auth(_))
141 }
142}
143
144pub 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}