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