Skip to main content

polyoxide_core/
error.rs

1use thiserror::Error;
2
3/// Core API error types shared across Polyoxide clients
4#[derive(Error, Debug)]
5pub enum ApiError {
6    /// HTTP request failed
7    #[error("API error: {status} - {message}")]
8    Api { status: u16, message: String },
9
10    /// Authentication failed (401/403)
11    #[error("Authentication failed: {0}")]
12    Authentication(String),
13
14    /// Request validation failed (400)
15    #[error("Validation error: {0}")]
16    Validation(String),
17
18    /// Rate limit exceeded (429)
19    #[error("Rate limit exceeded: {0}")]
20    RateLimit(String),
21
22    /// Request timeout
23    #[error("Request timeout")]
24    Timeout,
25
26    /// Network error
27    #[error("Network error: {0}")]
28    Network(#[from] reqwest::Error),
29
30    /// JSON serialization/deserialization error
31    #[error("Serialization error: {0}")]
32    Serialization(#[from] serde_json::Error),
33
34    /// URL parsing error
35    #[error("URL error: {0}")]
36    Url(#[from] url::ParseError),
37}
38
39impl ApiError {
40    /// Create error from HTTP response
41    pub async fn from_response(response: reqwest::Response) -> Self {
42        let status = response.status().as_u16();
43
44        // Get the raw response text first for debugging
45        let body_text = response.text().await.unwrap_or_default();
46        tracing::debug!("API error response body: {}", body_text);
47
48        Self::from_status_and_body(status, &body_text)
49    }
50
51    /// Classify an unsuccessful response from its status and body text.
52    ///
53    /// The body-reading half of [`from_response`](Self::from_response), for
54    /// callers that have to read the body themselves first, for example to
55    /// check it for a richer error shape before falling back to this one.
56    pub fn from_status_and_body(status: u16, body: &str) -> Self {
57        let message = serde_json::from_str::<serde_json::Value>(body)
58            .ok()
59            .and_then(|v| {
60                v.get("error")
61                    .or(v.get("message"))
62                    .and_then(|m| m.as_str())
63                    .map(String::from)
64            })
65            .unwrap_or_else(|| body.to_owned());
66
67        match status {
68            401 | 403 => Self::Authentication(message),
69            400 => Self::Validation(message),
70            429 => Self::RateLimit(message),
71            408 => Self::Timeout,
72            _ => Self::Api { status, message },
73        }
74    }
75
76    /// Whether re-sending the same request could plausibly produce a different result.
77    ///
78    /// Intended as the canonical input to a caller's retry policy, so consumers do
79    /// not have to re-derive retriability from status codes or error prose.
80    ///
81    /// Retriable: rate limits, timeouts, connection failures, `425 Too Early`
82    /// (Polymarket's matching engine restarting), and any 5xx. Not retriable:
83    /// authentication failures, validation failures, and local encode/decode errors —
84    /// all of which are deterministic for a given request.
85    ///
86    /// Note this describes the *error*, not the *operation*: a retriable error on a
87    /// non-idempotent request (order placement) still needs caller-side judgement
88    /// about whether resubmitting is safe.
89    pub fn is_retriable(&self) -> bool {
90        match self {
91            // 425 Too Early is the matching engine restarting; 5xx is a server fault.
92            // Both are documented upstream as "retry with exponential backoff".
93            Self::Api { status, .. } => *status == 425 || *status >= 500,
94            Self::RateLimit(_) | Self::Timeout => true,
95            Self::Network(e) => e.is_timeout() || e.is_connect(),
96            Self::Authentication(_) | Self::Validation(_) => false,
97            Self::Serialization(_) | Self::Url(_) => false,
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_rate_limit_error_carries_message() {
108        let err = ApiError::RateLimit("too many requests, retry after 5s".to_string());
109        let display = format!("{}", err);
110        assert!(
111            display.contains("too many requests"),
112            "RateLimit display should contain the message: {}",
113            display
114        );
115    }
116
117    #[test]
118    fn test_rate_limit_error_display_format() {
119        let err = ApiError::RateLimit("slow down".to_string());
120        assert_eq!(format!("{}", err), "Rate limit exceeded: slow down");
121    }
122
123    // ── from_status_and_body ────────────────────────────────────
124
125    #[test]
126    fn test_from_status_and_body_reads_error_then_message() {
127        assert!(matches!(
128            ApiError::from_status_and_body(400, r#"{"error":"bad limit"}"#),
129            ApiError::Validation(m) if m == "bad limit"
130        ));
131        assert!(matches!(
132            ApiError::from_status_and_body(503, r#"{"message":"down"}"#),
133            ApiError::Api { status: 503, message } if message == "down"
134        ));
135    }
136
137    #[test]
138    fn test_from_status_and_body_keeps_a_non_json_body_verbatim() {
139        // Cloudflare's IP block is plain text, not JSON.
140        assert!(matches!(
141            ApiError::from_status_and_body(429, "error code: 1015"),
142            ApiError::RateLimit(m) if m == "error code: 1015"
143        ));
144    }
145
146    // ── is_retriable ────────────────────────────────────────────
147
148    #[test]
149    fn test_retriable_transient_failures() {
150        assert!(ApiError::RateLimit("slow down".into()).is_retriable());
151        assert!(ApiError::Timeout.is_retriable());
152        for status in [500u16, 502, 503, 504] {
153            assert!(
154                ApiError::Api {
155                    status,
156                    message: String::new()
157                }
158                .is_retriable(),
159                "{status} should be retriable"
160            );
161        }
162    }
163
164    #[test]
165    fn test_retriable_425_too_early() {
166        // Polymarket returns 425 while the matching engine restarts and documents
167        // it as "retry with exponential backoff".
168        assert!(ApiError::Api {
169            status: 425,
170            message: String::new()
171        }
172        .is_retriable());
173    }
174
175    #[test]
176    fn test_not_retriable_deterministic_failures() {
177        assert!(!ApiError::Validation("bad payload".into()).is_retriable());
178        assert!(!ApiError::Authentication("Invalid API key".into()).is_retriable());
179        for status in [404u16, 409, 418] {
180            assert!(
181                !ApiError::Api {
182                    status,
183                    message: String::new()
184                }
185                .is_retriable(),
186                "{status} should not be retriable"
187            );
188        }
189    }
190
191    #[test]
192    fn test_not_retriable_local_encode_decode_failures() {
193        let json_err = serde_json::from_str::<String>("not json").unwrap_err();
194        assert!(!ApiError::Serialization(json_err).is_retriable());
195        let url_err = url::Url::parse("://bad").unwrap_err();
196        assert!(!ApiError::Url(url_err).is_retriable());
197    }
198}