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 std::time::Duration;
19
20use nautilus_network::http::{HttpClientError, ReqwestError, StatusCode};
21use thiserror::Error;
22
23const ORDER_VERSION_MISMATCH: &str = "order_version_mismatch";
24const ORDER_VERSION_MISMATCH_REASON: &str =
25    "Polymarket CLOB order version mismatch; adapter supports V2 only";
26
27/// Error type for Polymarket HTTP operations.
28#[derive(Debug, Error)]
29pub enum Error {
30    #[error("transport error: {0}")]
31    Transport(String),
32
33    #[error("serde error: {0}")]
34    Serde(#[from] serde_json::Error),
35
36    #[error("auth error: {0}")]
37    Auth(String),
38
39    #[error(
40        "HTTP 429 rate limit on {endpoint} (token_cost={token_cost}) retry_after_ms={retry_after_ms:?}: {message}"
41    )]
42    RateLimit {
43        endpoint: &'static str,
44        token_cost: u32,
45        retry_after_ms: Option<u64>,
46        message: String,
47        signer_limited: bool,
48    },
49
50    #[error("bad request: {0}")]
51    BadRequest(String),
52
53    #[error(
54        "bad request: {endpoint} token cost {token_cost} exceeds {tier} tier {bucket} burst {burst}"
55    )]
56    BurstExceeded {
57        endpoint: &'static str,
58        token_cost: u32,
59        tier: String,
60        bucket: String,
61        burst: u32,
62    },
63
64    #[error("exchange error: {0}")]
65    Exchange(String),
66
67    #[error("timeout")]
68    Timeout,
69
70    #[error("decode error: {0}")]
71    Decode(String),
72
73    #[error("HTTP error {status}: {message}")]
74    Http { status: u16, message: String },
75
76    #[error("URL parse error: {0}")]
77    UrlParse(#[from] url::ParseError),
78
79    #[error("IO error: {0}")]
80    Io(#[from] std::io::Error),
81}
82
83impl Error {
84    pub fn transport(msg: impl Into<String>) -> Self {
85        Self::Transport(msg.into())
86    }
87
88    pub fn auth(msg: impl Into<String>) -> Self {
89        Self::Auth(msg.into())
90    }
91
92    pub fn rate_limit(
93        endpoint: &'static str,
94        token_cost: u32,
95        retry_after_ms: Option<u64>,
96    ) -> Self {
97        Self::rate_limit_response(
98            endpoint,
99            token_cost,
100            retry_after_ms,
101            "rate limit exceeded",
102            false,
103        )
104    }
105
106    pub fn rate_limit_response(
107        endpoint: &'static str,
108        token_cost: u32,
109        retry_after_ms: Option<u64>,
110        message: impl Into<String>,
111        signer_limited: bool,
112    ) -> Self {
113        Self::RateLimit {
114            endpoint,
115            token_cost,
116            retry_after_ms,
117            message: message.into(),
118            signer_limited,
119        }
120    }
121
122    pub fn rate_limit_from_body(
123        endpoint: &'static str,
124        token_cost: u32,
125        retry_after_ms: Option<u64>,
126        body: &[u8],
127        signer_limited: bool,
128    ) -> Self {
129        Self::rate_limit_response(
130            endpoint,
131            token_cost,
132            retry_after_ms,
133            venue_error_message(body),
134            signer_limited,
135        )
136    }
137
138    pub fn bad_request(msg: impl Into<String>) -> Self {
139        Self::BadRequest(msg.into())
140    }
141
142    pub fn exchange(msg: impl Into<String>) -> Self {
143        Self::Exchange(msg.into())
144    }
145
146    pub fn decode(msg: impl Into<String>) -> Self {
147        Self::Decode(msg.into())
148    }
149
150    pub fn http(status: u16, message: impl Into<String>) -> Self {
151        Self::Http {
152            status,
153            message: message.into(),
154        }
155    }
156
157    /// Classifies an HTTP status code and body into the appropriate error variant.
158    pub fn from_http_status(status: StatusCode, body: &[u8]) -> Self {
159        Self::from_status_code(status.as_u16(), body)
160    }
161
162    /// Classifies a raw status code (as `u16`) and body into the appropriate error variant.
163    pub fn from_status_code(status: u16, body: &[u8]) -> Self {
164        let message = venue_error_message(body);
165
166        match status {
167            429 => Self::rate_limit_response("unknown", 0, None, message, false),
168            _ => Self::http(status, message),
169        }
170    }
171
172    /// Classifies a reqwest error into the appropriate error variant.
173    #[expect(clippy::needless_pass_by_value)]
174    pub fn from_reqwest(error: ReqwestError) -> Self {
175        if error.is_timeout() {
176            Self::Timeout
177        } else if let Some(status) = error.status() {
178            let status_code = status.as_u16();
179            match status_code {
180                429 => Self::rate_limit("unknown", 0, None),
181                _ => Self::http(status_code, format!("HTTP error: {error}")),
182            }
183        } else if error.is_connect() || error.is_request() {
184            Self::transport(format!("Request error: {error}"))
185        } else {
186            Self::transport(format!("Unknown reqwest error: {error}"))
187        }
188    }
189
190    pub fn from_http_client(error: HttpClientError) -> Self {
191        match error {
192            HttpClientError::TimeoutError(_) => Self::Timeout,
193            error => Self::transport(format!("HTTP client error: {error}")),
194        }
195    }
196
197    pub fn is_retryable(&self) -> bool {
198        match self {
199            Self::Transport(_) | Self::Timeout => true,
200            Self::RateLimit { .. } => true,
201            Self::Http { status, .. } => *status == 425 || *status >= 500,
202            _ => false,
203        }
204    }
205
206    /// Returns `true` when a submit POST may have reached the venue but the
207    /// adapter cannot prove whether the venue accepted or rejected the order.
208    ///
209    /// HTTP 425 and a 429 without CLOB signer-limiter headers stay unknown. A
210    /// 429 is definitive only when `Poly-RateLimit-Remaining`,
211    /// `Poly-RateLimit-Reset`, or `Poly-RateLimit-Tier` is present.
212    pub fn is_submit_outcome_unknown(&self) -> bool {
213        match self {
214            Self::Transport(_) | Self::Timeout | Self::Serde(_) | Self::Decode(_) | Self::Io(_) => {
215                true
216            }
217            Self::Http { status, .. } => *status == 425 || *status >= 500,
218            Self::RateLimit { signer_limited, .. } => !signer_limited,
219            Self::Auth(_)
220            | Self::BadRequest(_)
221            | Self::BurstExceeded { .. }
222            | Self::Exchange(_)
223            | Self::UrlParse(_) => false,
224        }
225    }
226
227    pub fn is_rate_limited(&self) -> bool {
228        matches!(self, Self::RateLimit { .. })
229    }
230
231    #[must_use]
232    pub fn retry_after(&self) -> Option<Duration> {
233        match self {
234            Self::RateLimit {
235                retry_after_ms: Some(retry_after_ms),
236                ..
237            } => Some(Duration::from_millis(*retry_after_ms)),
238            _ => None,
239        }
240    }
241
242    pub fn is_auth_error(&self) -> bool {
243        matches!(
244            self,
245            Self::Auth(_)
246                | Self::Http {
247                    status: 401 | 403,
248                    ..
249                }
250        )
251    }
252
253    /// Returns `true` if this error originated from an HTTP status code response
254    /// (as opposed to transport, timeout, or local errors).
255    pub fn is_http_status_error(&self) -> bool {
256        matches!(
257            self,
258            Self::Auth(_) | Self::BadRequest(_) | Self::RateLimit { .. } | Self::Http { .. }
259        )
260    }
261
262    /// Returns a bounded reason suitable for strategy-facing rejection events.
263    #[must_use]
264    pub fn strategy_reason(&self) -> String {
265        match self {
266            Self::Http { message, .. }
267            | Self::RateLimit { message, .. }
268            | Self::Exchange(message) => strategy_rejection_reason(message),
269            _ => strategy_rejection_reason(&self.to_string()),
270        }
271    }
272}
273
274/// Returns a bounded, actionable reason for a strategy-facing rejection event.
275#[must_use]
276pub(crate) fn strategy_rejection_reason(reason: &str) -> String {
277    let reason = sanitize_error_text(reason);
278
279    if reason == ORDER_VERSION_MISMATCH {
280        ORDER_VERSION_MISMATCH_REASON.to_string()
281    } else {
282        reason
283    }
284}
285
286// The CLOB reports a rejected request as `{"error": "..."}` and some endpoints use `errorMsg`,
287// alongside fields such as `orderID` that must not reach an `OrderRejected` reason.
288fn venue_error_message(body: &[u8]) -> String {
289    serde_json::from_slice::<serde_json::Value>(body)
290        .ok()
291        .and_then(|value| {
292            // Each key must clear the emptiness check on its own, so a blank `error` still falls
293            // through to a populated `errorMsg` instead of discarding both for the raw body
294            ["error", "errorMsg"].iter().find_map(|key| {
295                let message = value.get(key)?.as_str()?;
296                (!message.trim().is_empty()).then(|| sanitize_error_text(message))
297            })
298        })
299        .unwrap_or_else(|| sanitize_error_text(&String::from_utf8_lossy(body)))
300}
301
302const ERROR_TEXT_MAX_CHARS: usize = 512;
303const ERROR_TEXT_TRUNCATED_SUFFIX: &str = " ... [truncated]";
304
305/// Removes unsafe formatting and bounds venue text before logging or emitting it.
306#[must_use]
307pub(crate) fn sanitize_error_text(text: &str) -> String {
308    let visible = if looks_like_html(text) {
309        html_title(text).unwrap_or_else(|| strip_html_tags(text))
310    } else {
311        text.to_string()
312    };
313    let normalized = normalize_error_text(&visible);
314    let normalized = if normalized.is_empty() {
315        "empty response body".to_string()
316    } else {
317        normalized
318    };
319
320    if normalized.chars().count() <= ERROR_TEXT_MAX_CHARS {
321        return normalized;
322    }
323
324    let suffix_chars = ERROR_TEXT_TRUNCATED_SUFFIX.chars().count();
325    let mut bounded: String = normalized
326        .chars()
327        .take(ERROR_TEXT_MAX_CHARS - suffix_chars)
328        .collect();
329    bounded.push_str(ERROR_TEXT_TRUNCATED_SUFFIX);
330    bounded
331}
332
333fn looks_like_html(text: &str) -> bool {
334    let trimmed = text.trim_start().to_ascii_lowercase();
335
336    trimmed.starts_with("<!doctype html")
337        || trimmed.starts_with("<html")
338        || trimmed.contains("<html")
339}
340
341fn html_title(text: &str) -> Option<String> {
342    let lower = text.to_ascii_lowercase();
343    let title_start = lower.find("<title")?;
344    let content_start = title_start + lower[title_start..].find('>')? + 1;
345    let content_end = content_start + lower[content_start..].find("</title>")?;
346    let title = text[content_start..content_end].to_string();
347
348    (!title.trim().is_empty()).then_some(title)
349}
350
351fn strip_html_tags(text: &str) -> String {
352    let mut visible = String::with_capacity(text.len());
353    let mut in_tag = false;
354
355    for ch in text.chars() {
356        match ch {
357            '<' => {
358                in_tag = true;
359                visible.push(' ');
360            }
361            '>' => {
362                in_tag = false;
363                visible.push(' ');
364            }
365            _ if !in_tag => visible.push(ch),
366            _ => {}
367        }
368    }
369
370    visible
371}
372
373fn normalize_error_text(text: &str) -> String {
374    let mut normalized = String::with_capacity(text.len());
375    let mut previous_space = true;
376
377    for ch in text.chars() {
378        if ch.is_whitespace() || ch.is_control() {
379            if !previous_space {
380                normalized.push(' ');
381                previous_space = true;
382            }
383        } else {
384            normalized.push(ch);
385            previous_space = false;
386        }
387    }
388
389    if normalized.ends_with(' ') {
390        normalized.pop();
391    }
392
393    normalized
394}
395
396pub type Result<T> = std::result::Result<T, Error>;
397
398#[cfg(test)]
399mod tests {
400    use rstest::rstest;
401
402    use super::*;
403
404    #[rstest]
405    fn test_error_constructors() {
406        let transport_err = Error::transport("Connection failed");
407        assert!(matches!(transport_err, Error::Transport(_)));
408        assert_eq!(
409            transport_err.to_string(),
410            "transport error: Connection failed"
411        );
412
413        let auth_err = Error::auth("Invalid signature");
414        assert!(auth_err.is_auth_error());
415
416        let rate_limit_err = Error::rate_limit("test", 30, Some(30000));
417        assert!(rate_limit_err.is_rate_limited());
418        assert!(rate_limit_err.is_retryable());
419        assert_eq!(rate_limit_err.retry_after(), Some(Duration::from_secs(30)));
420
421        let http_err = Error::http(500, "Internal server error");
422        assert!(http_err.is_retryable());
423        assert_eq!(http_err.retry_after(), None);
424    }
425
426    #[rstest]
427    fn test_error_display() {
428        let err = Error::RateLimit {
429            endpoint: "/orders",
430            token_cost: 10,
431            retry_after_ms: Some(60000),
432            message: "slow down".to_string(),
433            signer_limited: false,
434        };
435        assert_eq!(
436            err.to_string(),
437            "HTTP 429 rate limit on /orders (token_cost=10) retry_after_ms=Some(60000): slow down"
438        );
439    }
440
441    #[rstest]
442    fn test_retryable_errors() {
443        assert!(Error::transport("test").is_retryable());
444        assert!(Error::Timeout.is_retryable());
445        assert!(Error::rate_limit("/orders", 10, Some(1_000)).is_retryable());
446        assert!(Error::rate_limit("unknown", 0, None).is_retryable());
447        assert!(Error::http(425, "matching engine restarting").is_retryable());
448        assert!(Error::http(500, "server error").is_retryable());
449
450        assert!(Error::rate_limit("/orders", 10, None).is_retryable());
451        assert!(!Error::auth("test").is_retryable());
452        assert!(!Error::bad_request("test").is_retryable());
453        assert!(!Error::http(404, "not found").is_retryable());
454        assert!(
455            !Error::BurstExceeded {
456                endpoint: "/orders",
457                token_cost: 121,
458                tier: "Standard".to_string(),
459                bucket: "cancel".to_string(),
460                burst: 120,
461            }
462            .is_retryable()
463        );
464        assert!(!Error::decode("test").is_retryable());
465    }
466
467    // The CLOB returns rejections as a structured body, so the message must carry the venue text
468    // alone. A killed FOK is the canonical case: its body also carries an `orderID` that would
469    // otherwise reach the strategy inside `OrderRejected.reason`.
470    #[rstest]
471    #[case::fok_killed(
472        400,
473        br#"{"error":"order couldn't be fully filled. FOK orders are fully filled or killed.","orderID":"0x3776d59db9ea1e4bbedf33f6f79ca677cfa6c93c2a44801f5a10516d822cc502"}"#,
474        "HTTP error 400: order couldn't be fully filled. FOK orders are fully filled or killed."
475    )]
476    #[case::error_msg_key(
477        400,
478        br#"{"errorMsg":"not enough balance / allowance: the balance is not enough"}"#,
479        "HTTP error 400: not enough balance / allowance: the balance is not enough"
480    )]
481    #[case::auth(
482        401,
483        br#"{"error":"invalid api key"}"#,
484        "HTTP error 401: invalid api key"
485    )]
486    #[case::server_error(
487        500,
488        br#"{"error":"internal error"}"#,
489        "HTTP error 500: internal error"
490    )]
491    #[case::plain_text_body(400, b"Bad Request", "HTTP error 400: Bad Request")]
492    #[case::json_without_error_key(400, br#"{"foo":"bar"}"#, r#"HTTP error 400: {"foo":"bar"}"#)]
493    #[case::empty_error_value(400, br#"{"error":""}"#, r#"HTTP error 400: {"error":""}"#)]
494    #[case::whitespace_error_value(400, br#"{"error":"   "}"#, r#"HTTP error 400: {"error":" "}"#)]
495    // A blank `error` must fall through too, otherwise the populated `errorMsg` beside it is
496    // discarded and the whole raw body reaches the reason.
497    #[case::blank_error_falls_through(
498        400,
499        br#"{"error":"","errorMsg":"not enough balance / allowance"}"#,
500        "HTTP error 400: not enough balance / allowance"
501    )]
502    // A null `error` must fall through to `errorMsg` rather than ending the lookup: the CLOB sends
503    // null for the unused key (see `test_data/http_order_response_ok.json`).
504    #[case::null_error_falls_through(
505        400,
506        br#"{"error":null,"errorMsg":"invalid post-only order: order crosses book"}"#,
507        "HTTP error 400: invalid post-only order: order crosses book"
508    )]
509    #[case::rate_limited_preserves_body(
510        429,
511        br#"{"error":"slow down"}"#,
512        "HTTP 429 rate limit on unknown (token_cost=0) retry_after_ms=None: slow down"
513    )]
514    #[case::empty_body(400, b"", "HTTP error 400: empty response body")]
515    fn test_from_status_code_message(
516        #[case] status: u16,
517        #[case] body: &[u8],
518        #[case] expected: &str,
519    ) {
520        assert_eq!(Error::from_status_code(status, body).to_string(), expected);
521    }
522
523    // Both entry points classify identically, so a caller cannot get the raw body through one and
524    // the venue text through the other.
525    #[rstest]
526    fn test_from_http_status_matches_from_status_code() {
527        let body = br#"{"error":"order couldn't be fully filled. FOK orders are fully filled or killed."}"#;
528
529        let from_status = Error::from_http_status(StatusCode::BAD_REQUEST, body);
530        let from_code = Error::from_status_code(400, body);
531
532        assert_eq!(from_status.to_string(), from_code.to_string());
533        assert_eq!(
534            from_status.to_string(),
535            "HTTP error 400: order couldn't be fully filled. FOK orders are fully filled or killed."
536        );
537    }
538
539    #[rstest]
540    #[case::bad_request(400, false, false, false)]
541    #[case::unauthorized(401, false, false, true)]
542    #[case::forbidden(403, false, false, true)]
543    #[case::not_found(404, false, false, false)]
544    #[case::too_early(425, true, true, false)]
545    #[case::rate_limited(429, true, true, false)]
546    #[case::server_error(500, true, true, false)]
547    #[case::service_unavailable(503, true, true, false)]
548    fn test_http_status_classification(
549        #[case] status: u16,
550        #[case] retryable: bool,
551        #[case] outcome_unknown: bool,
552        #[case] auth: bool,
553    ) {
554        let error = Error::from_status_code(status, br#"{"error":"venue message"}"#);
555
556        assert_eq!(error.is_retryable(), retryable);
557        assert_eq!(error.is_submit_outcome_unknown(), outcome_unknown);
558        assert_eq!(error.is_auth_error(), auth);
559        assert!(error.is_http_status_error());
560        assert_eq!(error.strategy_reason(), "venue message");
561    }
562
563    #[rstest]
564    fn test_order_version_mismatch_strategy_reason_is_actionable() {
565        let error = Error::from_status_code(400, br#"{"error":"order_version_mismatch"}"#);
566
567        assert!(!error.is_submit_outcome_unknown());
568        assert_eq!(
569            error.strategy_reason(),
570            "Polymarket CLOB order version mismatch; adapter supports V2 only"
571        );
572        assert_eq!(
573            strategy_rejection_reason(" order_version_mismatch "),
574            "Polymarket CLOB order version mismatch; adapter supports V2 only"
575        );
576        assert_eq!(
577            strategy_rejection_reason("ORDER_VERSION_MISMATCH"),
578            "ORDER_VERSION_MISMATCH"
579        );
580    }
581
582    #[rstest]
583    #[case::empty(b"", "empty response body")]
584    #[case::plain_text(b"  Bad\r\nRequest  ", "Bad Request")]
585    #[case::malformed_json(br#"{"error":"broken"#, r#"{"error":"broken"#)]
586    #[case::structured_error(br#"{"error":"  insufficient\n balance  "}"#, "insufficient balance")]
587    #[case::structured_error_msg(br#"{"errorMsg":"placement failed"}"#, "placement failed")]
588    #[case::unknown_json(
589        br#"{"message":"not a verified key"}"#,
590        r#"{"message":"not a verified key"}"#
591    )]
592    #[case::html(
593        b"<!doctype html><html><head><title>502 Bad Gateway</title></head><body><h1>cloud proxy</h1></body></html>",
594        "502 Bad Gateway"
595    )]
596    fn test_venue_error_message_shapes(#[case] body: &[u8], #[case] expected: &str) {
597        assert_eq!(venue_error_message(body), expected);
598    }
599
600    #[rstest]
601    fn test_venue_error_message_is_bounded_by_unicode_characters() {
602        let body = format!(r#"{{"error":"{}"}}"#, "é".repeat(600));
603        let message = venue_error_message(body.as_bytes());
604
605        assert_eq!(message.chars().count(), ERROR_TEXT_MAX_CHARS);
606        assert!(message.ends_with(ERROR_TEXT_TRUNCATED_SUFFIX));
607    }
608
609    #[rstest]
610    fn test_raw_fallback_is_lossy_and_bounded() {
611        let mut body = vec![b'x'; 600];
612        body[10] = 0xff;
613
614        let message = venue_error_message(&body);
615
616        assert_eq!(message.chars().count(), ERROR_TEXT_MAX_CHARS);
617        assert_eq!(message.chars().nth(10), Some(char::REPLACEMENT_CHARACTER));
618        assert!(message.ends_with(ERROR_TEXT_TRUNCATED_SUFFIX));
619    }
620
621    #[rstest]
622    fn test_from_http_client_preserves_timeout_classification() {
623        let timeout = Error::from_http_client(HttpClientError::TimeoutError("late".to_string()));
624        let transport = Error::from_http_client(HttpClientError::Error("reset".to_string()));
625
626        assert!(matches!(timeout, Error::Timeout));
627        assert!(matches!(transport, Error::Transport(_)));
628        assert!(timeout.is_retryable());
629        assert!(timeout.is_submit_outcome_unknown());
630    }
631
632    #[rstest]
633    fn test_submit_outcome_unknown_errors() {
634        assert!(Error::transport("test").is_submit_outcome_unknown());
635        assert!(Error::Timeout.is_submit_outcome_unknown());
636        assert!(Error::http(500, "server error").is_submit_outcome_unknown());
637        assert!(Error::decode("bad json").is_submit_outcome_unknown());
638
639        assert!(Error::rate_limit("/orders", 10, Some(1_000)).is_submit_outcome_unknown());
640        assert!(Error::http(425, "too early").is_submit_outcome_unknown());
641        assert!(!Error::auth("test").is_submit_outcome_unknown());
642        assert!(!Error::bad_request("test").is_submit_outcome_unknown());
643        assert!(!Error::http(404, "not found").is_submit_outcome_unknown());
644        assert!(
645            !Error::RateLimit {
646                endpoint: "/order",
647                token_cost: 1,
648                retry_after_ms: Some(1_000),
649                message: "slow down".to_string(),
650                signer_limited: true,
651            }
652            .is_submit_outcome_unknown()
653        );
654    }
655}