Skip to main content

polyoxide_clob/
error.rs

1use polyoxide_core::ApiError;
2use thiserror::Error;
3
4use crate::types::ParseTickSizeError;
5
6/// Error types for CLOB API operations.
7///
8/// `#[non_exhaustive]`: downstream matches must carry a wildcard arm. Polymarket
9/// keeps introducing outcomes that are only distinguishable by prose (see
10/// [`ClobError::FakUnmatched`]), so this enum will keep growing, and each new
11/// variant should be a minor bump rather than a breaking one.
12#[derive(Error, Debug)]
13#[non_exhaustive]
14pub enum ClobError {
15    /// Core API error
16    #[error(transparent)]
17    Api(#[from] ApiError),
18
19    /// Cryptographic operation failed
20    #[error("Crypto error: {0}")]
21    Crypto(String),
22
23    /// Alloy (Ethereum library) error
24    #[error("Alloy error: {0}")]
25    Alloy(String),
26
27    /// Invalid tick size
28    #[error(transparent)]
29    InvalidTickSize(#[from] ParseTickSizeError),
30
31    /// A Fill-And-Kill order was killed because nothing on the book matched it.
32    ///
33    /// This is FAK's *defined* outcome, not a fault: the order was accepted,
34    /// evaluated against the book, found no counterparty, and was killed exactly
35    /// as its time-in-force specifies. It is reported as an error only because
36    /// Polymarket returns it as HTTP 400, in the same bucket as malformed
37    /// payloads and banned addresses.
38    ///
39    /// It is deterministic — resubmitting the identical order cannot change the
40    /// answer — so [`ClobError::is_retriable`] returns `false` for it.
41    ///
42    /// `message` carries the venue's prose verbatim for logging. Match on the
43    /// variant, not on the text.
44    #[error("FAK order killed unmatched: {message}")]
45    FakUnmatched { message: String },
46
47    /// A Fill-Or-Kill order was killed because it could not be filled in full.
48    ///
49    /// The FOK counterpart of [`ClobError::FakUnmatched`], and equally a defined
50    /// outcome rather than a fault. Reached by [`crate::Clob::place_market_order`],
51    /// which defaults to [`crate::OrderKind::Fok`].
52    #[error("FOK order killed unfilled: {message}")]
53    FokUnfilled { message: String },
54}
55
56/// Recognise the matching-engine kill outcomes Polymarket reports as HTTP 400.
57///
58/// The venue gives no machine-readable discriminator — no error code, no
59/// distinct status — so the message body is the only available signal. See
60/// <https://docs.polymarket.com/resources/error-codes>, "Order Processing
61/// Errors":
62///
63/// - `no orders found to match with FAK order. FAK orders are partially filled
64///   or killed if no match is found.`
65/// - `order couldn't be fully filled. FOK orders are fully filled or killed.`
66///
67/// Each arm requires both an order-kind token and a kill token, so it stays
68/// narrow enough not to capture the neighbouring 400s (tick size, duplicate
69/// order, insufficient balance) while surviving light rewording. The apostrophe
70/// in "couldn't" is deliberately not part of any key, since straight/curly
71/// quoting is exactly the kind of detail that changes silently.
72///
73/// If Polymarket rewrites these messages, this returns `None` and the caller
74/// sees the previous generic [`ApiError::Validation`] behaviour — a visible
75/// regression to the old symptom, not a silent misclassification.
76fn classify_order_kill(message: &str) -> Option<ClobError> {
77    let m = message.to_ascii_lowercase();
78    let owned = || message.to_string();
79
80    if m.contains("fak order") && (m.contains("no match") || m.contains("no orders found")) {
81        return Some(ClobError::FakUnmatched { message: owned() });
82    }
83    if m.contains("fok order") && (m.contains("fully filled") || m.contains("killed")) {
84        return Some(ClobError::FokUnfilled { message: owned() });
85    }
86    None
87}
88
89impl ClobError {
90    /// Create error from HTTP response
91    pub(crate) async fn from_response(response: reqwest::Response) -> Self {
92        // Classify only bodies authored by the venue. Matching on `Validation`
93        // rather than re-reading the status is what gates this to HTTP 400:
94        // `ApiError::from_response` produces that variant for 400 and nothing
95        // else, so the two cannot drift apart. A 5xx carrying similar prose is
96        // an engine fault and stays retriable.
97        match ApiError::from_response(response).await {
98            ApiError::Validation(msg) => {
99                classify_order_kill(&msg).unwrap_or(Self::Api(ApiError::Validation(msg)))
100            }
101            other => Self::Api(other),
102        }
103    }
104
105    /// Whether re-sending the same request could plausibly produce a different result.
106    ///
107    /// Delegates to [`ApiError::is_retriable`] for transport and HTTP failures.
108    /// The FAK/FOK kill outcomes and all local signing/encoding failures are
109    /// deterministic and return `false`.
110    ///
111    /// ```
112    /// # use polyoxide_clob::ClobError;
113    /// let killed = ClobError::FakUnmatched { message: "no orders found to match".into() };
114    /// assert!(!killed.is_retriable());
115    /// ```
116    pub fn is_retriable(&self) -> bool {
117        match self {
118            Self::Api(e) => e.is_retriable(),
119            Self::FakUnmatched { .. } | Self::FokUnfilled { .. } => false,
120            Self::Crypto(_) | Self::Alloy(_) | Self::InvalidTickSize(_) => false,
121        }
122    }
123
124    /// Create validation error
125    pub(crate) fn validation(msg: impl Into<String>) -> Self {
126        Self::Api(ApiError::Validation(msg.into()))
127    }
128
129    /// Create service error (external dependency failure)
130    #[cfg_attr(not(feature = "gamma"), allow(dead_code))]
131    pub(crate) fn service(msg: impl Into<String>) -> Self {
132        Self::Api(ApiError::Api {
133            status: 0,
134            message: msg.into(),
135        })
136    }
137}
138
139impl From<alloy::signers::Error> for ClobError {
140    fn from(err: alloy::signers::Error) -> Self {
141        Self::Alloy(err.to_string())
142    }
143}
144
145impl From<alloy::hex::FromHexError> for ClobError {
146    fn from(err: alloy::hex::FromHexError) -> Self {
147        Self::Alloy(err.to_string())
148    }
149}
150
151impl From<reqwest::Error> for ClobError {
152    fn from(err: reqwest::Error) -> Self {
153        Self::Api(ApiError::Network(err))
154    }
155}
156
157impl From<url::ParseError> for ClobError {
158    fn from(err: url::ParseError) -> Self {
159        Self::Api(ApiError::Url(err))
160    }
161}
162
163impl From<serde_json::Error> for ClobError {
164    fn from(err: serde_json::Error) -> Self {
165        Self::Api(ApiError::Serialization(err))
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_service_error_is_api_not_validation() {
175        let err = ClobError::service("Gamma client failed");
176        match &err {
177            ClobError::Api(ApiError::Api { status, message }) => {
178                assert_eq!(*status, 0);
179                assert_eq!(message, "Gamma client failed");
180            }
181            other => panic!("Expected ApiError::Api, got {:?}", other),
182        }
183    }
184
185    #[test]
186    fn test_validation_error() {
187        let err = ClobError::validation("bad input");
188        match &err {
189            ClobError::Api(ApiError::Validation(msg)) => {
190                assert_eq!(msg, "bad input");
191            }
192            other => panic!("Expected ApiError::Validation, got {:?}", other),
193        }
194    }
195
196    #[test]
197    fn test_service_and_validation_are_distinct() {
198        let service = ClobError::service("service failure");
199        let validation = ClobError::validation("validation failure");
200
201        let service_msg = format!("{}", service);
202        let validation_msg = format!("{}", validation);
203
204        // They should produce different Display output
205        assert_ne!(service_msg, validation_msg);
206        assert!(service_msg.contains("service failure"));
207        assert!(validation_msg.contains("validation failure"));
208    }
209
210    #[test]
211    fn test_crypto_error() {
212        let err = ClobError::Crypto("signing failed".into());
213        assert!(err.to_string().contains("signing failed"));
214        assert!(matches!(err, ClobError::Crypto(_)));
215    }
216
217    #[test]
218    fn test_alloy_error() {
219        let err = ClobError::Alloy("hex decode failed".into());
220        assert!(err.to_string().contains("hex decode failed"));
221        assert!(matches!(err, ClobError::Alloy(_)));
222    }
223
224    #[test]
225    fn test_invalid_tick_size_from_str() {
226        let err: Result<crate::types::TickSize, _> = "0.5".try_into();
227        let clob_err = ClobError::from(err.unwrap_err());
228        assert!(matches!(clob_err, ClobError::InvalidTickSize(_)));
229        assert!(clob_err.to_string().contains("0.5"));
230    }
231
232    #[test]
233    fn test_from_serde_json_error() {
234        let json_err = serde_json::from_str::<String>("not valid json").unwrap_err();
235        let clob_err = ClobError::from(json_err);
236        assert!(matches!(
237            clob_err,
238            ClobError::Api(ApiError::Serialization(_))
239        ));
240    }
241
242    #[test]
243    fn test_from_url_parse_error() {
244        let url_err = url::Url::parse("://bad").unwrap_err();
245        let clob_err = ClobError::from(url_err);
246        assert!(matches!(clob_err, ClobError::Api(ApiError::Url(_))));
247    }
248
249    // ── FAK/FOK kill classification ─────────────────────────────
250
251    /// Verbatim venue prose, from docs.polymarket.com/resources/error-codes.
252    const FAK_UNMATCHED: &str = "no orders found to match with FAK order. \
253FAK orders are partially filled or killed if no match is found.";
254    const FOK_UNFILLED: &str =
255        "order couldn't be fully filled. FOK orders are fully filled or killed.";
256
257    #[test]
258    fn test_classify_recognizes_verbatim_venue_messages() {
259        assert!(matches!(
260            classify_order_kill(FAK_UNMATCHED),
261            Some(ClobError::FakUnmatched { .. })
262        ));
263        assert!(matches!(
264            classify_order_kill(FOK_UNFILLED),
265            Some(ClobError::FokUnfilled { .. })
266        ));
267    }
268
269    #[test]
270    fn test_classify_preserves_message_verbatim() {
271        match classify_order_kill(FAK_UNMATCHED) {
272            Some(ClobError::FakUnmatched { message }) => assert_eq!(message, FAK_UNMATCHED),
273            other => panic!("expected FakUnmatched, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn test_classify_is_case_insensitive() {
279        // The venue capitalizes "FAK"/"FOK"; casing must not be load-bearing.
280        assert!(matches!(
281            classify_order_kill(&FAK_UNMATCHED.to_uppercase()),
282            Some(ClobError::FakUnmatched { .. })
283        ));
284        assert!(matches!(
285            classify_order_kill(&FOK_UNFILLED.to_lowercase()),
286            Some(ClobError::FokUnfilled { .. })
287        ));
288    }
289
290    #[test]
291    fn test_classify_tolerates_curly_apostrophe_in_fok_message() {
292        // "couldn't" is not part of any match key precisely so that straight vs
293        // curly quoting cannot break classification.
294        let curly = "order couldn\u{2019}t be fully filled. FOK orders are fully filled or killed.";
295        assert!(matches!(
296            classify_order_kill(curly),
297            Some(ClobError::FokUnfilled { .. })
298        ));
299    }
300
301    #[test]
302    fn test_classify_does_not_capture_neighbouring_400s() {
303        // Every other documented 400 from the "Place Orders" and "Order Processing
304        // Errors" tables. A kill classifier that swallowed any of these would turn a
305        // real fault into a normal-outcome signal — strictly worse than the bug.
306        for msg in [
307            "Invalid order payload",
308            "the order owner has to be the owner of the API KEY",
309            "the order signer address has to be the address of the API KEY",
310            "'0x1234' address banned",
311            "'0x1234' address in closed only mode",
312            "Too many orders in payload: 20, max allowed: 15",
313            "invalid post-only order: order crosses book",
314            "order 0xabc is invalid. Price (100) breaks minimum tick size rule: 0.1",
315            "order 0xabc is invalid. Size (1) lower than the minimum: 5",
316            "order 0xabc is invalid. Duplicated.",
317            "order 0xabc crosses the book",
318            "not enough balance / allowance",
319            "invalid expiration",
320            "order canceled in the CTF exchange contract",
321            "order match delayed due to market conditions",
322            "the market is not yet ready to process new orders",
323            "invalid amount for a marketable BUY order ($0.50), min size: 1",
324        ] {
325            assert!(
326                classify_order_kill(msg).is_none(),
327                "must not classify as a kill outcome: {msg}"
328            );
329        }
330    }
331
332    #[test]
333    fn test_classify_requires_both_tokens() {
334        // An order-kind token alone is not enough, nor a kill token alone.
335        assert!(classify_order_kill("FAK order rejected: bad payload").is_none());
336        assert!(classify_order_kill("no orders found for this market").is_none());
337        assert!(classify_order_kill("FOK order rejected: bad payload").is_none());
338    }
339
340    // ── is_retriable ────────────────────────────────────────────
341
342    #[test]
343    fn test_kill_outcomes_are_not_retriable() {
344        // The point of the whole change: an unmatched FAK is deterministic.
345        assert!(!ClobError::FakUnmatched {
346            message: FAK_UNMATCHED.into()
347        }
348        .is_retriable());
349        assert!(!ClobError::FokUnfilled {
350            message: FOK_UNFILLED.into()
351        }
352        .is_retriable());
353    }
354
355    #[test]
356    fn test_local_failures_are_not_retriable() {
357        assert!(!ClobError::Crypto("signing failed".into()).is_retriable());
358        assert!(!ClobError::Alloy("hex decode failed".into()).is_retriable());
359        assert!(!ClobError::validation("bad input").is_retriable());
360    }
361
362    #[test]
363    fn test_transient_failures_are_retriable() {
364        assert!(ClobError::Api(ApiError::RateLimit("slow down".into())).is_retriable());
365        assert!(ClobError::Api(ApiError::Timeout).is_retriable());
366        assert!(ClobError::Api(ApiError::Api {
367            status: 500,
368            message: "order timed out".into()
369        })
370        .is_retriable());
371        // 425 Too Early — matching engine restarting.
372        assert!(ClobError::Api(ApiError::Api {
373            status: 425,
374            message: String::new()
375        })
376        .is_retriable());
377    }
378
379    #[test]
380    fn test_kill_outcome_display_names_the_order_type() {
381        // Logs should say what happened without the reader parsing venue prose.
382        let fak = ClobError::FakUnmatched {
383            message: FAK_UNMATCHED.into(),
384        };
385        assert!(fak.to_string().starts_with("FAK order killed unmatched:"));
386        let fok = ClobError::FokUnfilled {
387            message: FOK_UNFILLED.into(),
388        };
389        assert!(fok.to_string().starts_with("FOK order killed unfilled:"));
390    }
391}