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