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        let message = serde_json::from_str::<serde_json::Value>(&body_text)
49            .ok()
50            .and_then(|v| {
51                v.get("error")
52                    .or(v.get("message"))
53                    .and_then(|m| m.as_str())
54                    .map(String::from)
55            })
56            .unwrap_or_else(|| body_text.clone());
57
58        match status {
59            401 | 403 => Self::Authentication(message),
60            400 => Self::Validation(message),
61            429 => Self::RateLimit(message),
62            408 => Self::Timeout,
63            _ => Self::Api { status, message },
64        }
65    }
66
67    /// Whether re-sending the same request could plausibly produce a different result.
68    ///
69    /// Intended as the canonical input to a caller's retry policy, so consumers do
70    /// not have to re-derive retriability from status codes or error prose.
71    ///
72    /// Retriable: rate limits, timeouts, connection failures, `425 Too Early`
73    /// (Polymarket's matching engine restarting), and any 5xx. Not retriable:
74    /// authentication failures, validation failures, and local encode/decode errors —
75    /// all of which are deterministic for a given request.
76    ///
77    /// Note this describes the *error*, not the *operation*: a retriable error on a
78    /// non-idempotent request (order placement) still needs caller-side judgement
79    /// about whether resubmitting is safe.
80    pub fn is_retriable(&self) -> bool {
81        match self {
82            // 425 Too Early is the matching engine restarting; 5xx is a server fault.
83            // Both are documented upstream as "retry with exponential backoff".
84            Self::Api { status, .. } => *status == 425 || *status >= 500,
85            Self::RateLimit(_) | Self::Timeout => true,
86            Self::Network(e) => e.is_timeout() || e.is_connect(),
87            Self::Authentication(_) | Self::Validation(_) => false,
88            Self::Serialization(_) | Self::Url(_) => false,
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_rate_limit_error_carries_message() {
99        let err = ApiError::RateLimit("too many requests, retry after 5s".to_string());
100        let display = format!("{}", err);
101        assert!(
102            display.contains("too many requests"),
103            "RateLimit display should contain the message: {}",
104            display
105        );
106    }
107
108    #[test]
109    fn test_rate_limit_error_display_format() {
110        let err = ApiError::RateLimit("slow down".to_string());
111        assert_eq!(format!("{}", err), "Rate limit exceeded: slow down");
112    }
113
114    // ── is_retriable ────────────────────────────────────────────
115
116    #[test]
117    fn test_retriable_transient_failures() {
118        assert!(ApiError::RateLimit("slow down".into()).is_retriable());
119        assert!(ApiError::Timeout.is_retriable());
120        for status in [500u16, 502, 503, 504] {
121            assert!(
122                ApiError::Api {
123                    status,
124                    message: String::new()
125                }
126                .is_retriable(),
127                "{status} should be retriable"
128            );
129        }
130    }
131
132    #[test]
133    fn test_retriable_425_too_early() {
134        // Polymarket returns 425 while the matching engine restarts and documents
135        // it as "retry with exponential backoff".
136        assert!(ApiError::Api {
137            status: 425,
138            message: String::new()
139        }
140        .is_retriable());
141    }
142
143    #[test]
144    fn test_not_retriable_deterministic_failures() {
145        assert!(!ApiError::Validation("bad payload".into()).is_retriable());
146        assert!(!ApiError::Authentication("Invalid API key".into()).is_retriable());
147        for status in [404u16, 409, 418] {
148            assert!(
149                !ApiError::Api {
150                    status,
151                    message: String::new()
152                }
153                .is_retriable(),
154                "{status} should not be retriable"
155            );
156        }
157    }
158
159    #[test]
160    fn test_not_retriable_local_encode_decode_failures() {
161        let json_err = serde_json::from_str::<String>("not json").unwrap_err();
162        assert!(!ApiError::Serialization(json_err).is_retriable());
163        let url_err = url::Url::parse("://bad").unwrap_err();
164        assert!(!ApiError::Url(url_err).is_retriable());
165    }
166}