Skip to main content

nautilus_polymarket/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
16//! HTTP error types for the Polymarket adapter.
17
18use nautilus_network::http::{HttpClientError, ReqwestError, StatusCode};
19use thiserror::Error;
20
21/// Error type for Polymarket HTTP operations.
22#[derive(Debug, Error)]
23pub enum Error {
24    #[error("transport error: {0}")]
25    Transport(String),
26
27    #[error("serde error: {0}")]
28    Serde(#[from] serde_json::Error),
29
30    #[error("auth error: {0}")]
31    Auth(String),
32
33    #[error("Rate limited on {scope} (weight={weight}) retry_after_ms={retry_after_ms:?}")]
34    RateLimit {
35        scope: &'static str,
36        weight: u32,
37        retry_after_ms: Option<u64>,
38    },
39
40    #[error("bad request: {0}")]
41    BadRequest(String),
42
43    #[error("exchange error: {0}")]
44    Exchange(String),
45
46    #[error("timeout")]
47    Timeout,
48
49    #[error("decode error: {0}")]
50    Decode(String),
51
52    #[error("HTTP error {status}: {message}")]
53    Http { status: u16, message: String },
54
55    #[error("URL parse error: {0}")]
56    UrlParse(#[from] url::ParseError),
57
58    #[error("IO error: {0}")]
59    Io(#[from] std::io::Error),
60}
61
62impl Error {
63    pub fn transport(msg: impl Into<String>) -> Self {
64        Self::Transport(msg.into())
65    }
66
67    pub fn auth(msg: impl Into<String>) -> Self {
68        Self::Auth(msg.into())
69    }
70
71    pub fn rate_limit(scope: &'static str, weight: u32, retry_after_ms: Option<u64>) -> Self {
72        Self::RateLimit {
73            scope,
74            weight,
75            retry_after_ms,
76        }
77    }
78
79    pub fn bad_request(msg: impl Into<String>) -> Self {
80        Self::BadRequest(msg.into())
81    }
82
83    pub fn exchange(msg: impl Into<String>) -> Self {
84        Self::Exchange(msg.into())
85    }
86
87    pub fn decode(msg: impl Into<String>) -> Self {
88        Self::Decode(msg.into())
89    }
90
91    pub fn http(status: u16, message: impl Into<String>) -> Self {
92        Self::Http {
93            status,
94            message: message.into(),
95        }
96    }
97
98    /// Classifies an HTTP status code and body into the appropriate error variant.
99    pub fn from_http_status(status: StatusCode, body: &[u8]) -> Self {
100        let message = String::from_utf8_lossy(body).to_string();
101        match status.as_u16() {
102            401 | 403 => Self::auth(format!("HTTP {}: {message}", status.as_u16())),
103            400 => Self::bad_request(format!("HTTP {}: {message}", status.as_u16())),
104            429 => Self::rate_limit("unknown", 0, None),
105            _ => Self::http(status.as_u16(), message),
106        }
107    }
108
109    /// Classifies a raw status code (as `u16`) and body into the appropriate error variant.
110    pub fn from_status_code(status: u16, body: &[u8]) -> Self {
111        let message = String::from_utf8_lossy(body).to_string();
112        match status {
113            401 | 403 => Self::auth(format!("HTTP {status}: {message}")),
114            400 => Self::bad_request(format!("HTTP {status}: {message}")),
115            429 => Self::rate_limit("unknown", 0, None),
116            _ => Self::http(status, message),
117        }
118    }
119
120    /// Classifies a reqwest error into the appropriate error variant.
121    #[expect(clippy::needless_pass_by_value)]
122    pub fn from_reqwest(error: ReqwestError) -> Self {
123        if error.is_timeout() {
124            Self::Timeout
125        } else if let Some(status) = error.status() {
126            let status_code = status.as_u16();
127            match status_code {
128                401 | 403 => Self::auth(format!("HTTP {status_code}: authentication failed")),
129                400 => Self::bad_request(format!("HTTP {status_code}: bad request")),
130                429 => Self::rate_limit("unknown", 0, None),
131                _ => Self::http(status_code, format!("HTTP error: {error}")),
132            }
133        } else if error.is_connect() || error.is_request() {
134            Self::transport(format!("Request error: {error}"))
135        } else {
136            Self::transport(format!("Unknown reqwest error: {error}"))
137        }
138    }
139
140    #[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 {
146        match self {
147            Self::Transport(_) | Self::Timeout | Self::RateLimit { .. } => true,
148            Self::Http { status, .. } => *status >= 500,
149            _ => false,
150        }
151    }
152
153    pub fn is_rate_limited(&self) -> bool {
154        matches!(self, Self::RateLimit { .. })
155    }
156
157    pub fn is_auth_error(&self) -> bool {
158        matches!(self, Self::Auth(_))
159    }
160
161    /// Returns `true` if this error originated from an HTTP status code response
162    /// (as opposed to transport, timeout, or local errors).
163    pub fn is_http_status_error(&self) -> bool {
164        matches!(
165            self,
166            Self::Auth(_) | Self::BadRequest(_) | Self::RateLimit { .. } | Self::Http { .. }
167        )
168    }
169}
170
171pub type Result<T> = std::result::Result<T, Error>;
172
173#[cfg(test)]
174mod tests {
175    use rstest::rstest;
176
177    use super::*;
178
179    #[rstest]
180    fn test_error_constructors() {
181        let transport_err = Error::transport("Connection failed");
182        assert!(matches!(transport_err, Error::Transport(_)));
183        assert_eq!(
184            transport_err.to_string(),
185            "transport error: Connection failed"
186        );
187
188        let auth_err = Error::auth("Invalid signature");
189        assert!(auth_err.is_auth_error());
190
191        let rate_limit_err = Error::rate_limit("test", 30, Some(30000));
192        assert!(rate_limit_err.is_rate_limited());
193        assert!(rate_limit_err.is_retryable());
194
195        let http_err = Error::http(500, "Internal server error");
196        assert!(http_err.is_retryable());
197    }
198
199    #[rstest]
200    fn test_error_display() {
201        let err = Error::RateLimit {
202            scope: "order",
203            weight: 10,
204            retry_after_ms: Some(60000),
205        };
206        assert_eq!(
207            err.to_string(),
208            "Rate limited on order (weight=10) retry_after_ms=Some(60000)"
209        );
210    }
211
212    #[rstest]
213    fn test_retryable_errors() {
214        assert!(Error::transport("test").is_retryable());
215        assert!(Error::Timeout.is_retryable());
216        assert!(Error::rate_limit("test", 10, None).is_retryable());
217        assert!(Error::http(500, "server error").is_retryable());
218
219        assert!(!Error::auth("test").is_retryable());
220        assert!(!Error::bad_request("test").is_retryable());
221        assert!(!Error::decode("test").is_retryable());
222    }
223}