1use 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#[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 pub fn from_http_status(status: StatusCode, body: &[u8]) -> Self {
160 Self::from_status_code(status.as_u16(), body)
161 }
162
163 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 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 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 #[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#[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
280fn venue_error_message(body: &[u8]) -> String {
283 serde_json::from_slice::<serde_json::Value>(body)
284 .ok()
285 .and_then(|value| {
286 ["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#[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 #[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 #[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 #[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 #[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}