odos_sdk/error.rs
1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{fmt, time::Duration};
6
7use alloy_primitives::hex;
8use reqwest::StatusCode;
9use thiserror::Error;
10
11use crate::{
12 error_code::{OdosErrorCode, TraceId},
13 OdosChainError,
14};
15
16/// Result type alias for Odos SDK operations
17pub type Result<T> = std::result::Result<T, OdosError>;
18
19/// Shared payload for API-shaped [`OdosError`] variants.
20///
21/// `ApiErrorBody` collects the fields that are common to every error the Odos
22/// service returns over HTTP — the human-readable message, the strongly-typed
23/// [`OdosErrorCode`], and an optional [`TraceId`] for support correspondence.
24/// It is used by both [`OdosError::Api`] (status-bearing failures) and
25/// [`OdosError::RateLimit`] (retry-after-bearing failures) so the orthogonal
26/// per-variant fields are the only thing those variants carry directly.
27///
28/// The [`Display`](fmt::Display) impl renders `"<message>[ [trace: <id>]]"`,
29/// which the surrounding variants embed into their own format strings.
30#[derive(Debug, Clone)]
31pub struct ApiErrorBody {
32 /// Human-readable error message returned by the API.
33 pub message: String,
34 /// Strongly-typed Odos error code.
35 pub code: OdosErrorCode,
36 /// Trace ID for support correspondence, if the API returned one.
37 pub trace_id: Option<TraceId>,
38}
39
40impl fmt::Display for ApiErrorBody {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(&self.message)?;
43 if let Some(trace_id) = self.trace_id {
44 write!(f, " [trace: {trace_id}]")?;
45 }
46 Ok(())
47 }
48}
49
50/// Comprehensive error types for the Odos SDK
51///
52/// This enum provides detailed error types for different failure scenarios,
53/// allowing users to handle specific error conditions appropriately.
54///
55/// ## Error Categories
56///
57/// - **Network Errors**: HTTP, timeout, and connectivity issues
58/// - **API Errors**: Responses from the Odos service indicating various failures
59/// - **Input Errors**: Invalid parameters or missing required data
60/// - **System Errors**: Rate limiting and internal failures
61///
62/// ## Retryable Errors
63///
64/// Some error types are marked as retryable (see [`OdosError::is_retryable`]):
65/// - Timeout errors
66/// - Certain HTTP errors (5xx status codes, connection issues)
67/// - Some API errors (server errors)
68///
69/// **Note**: Rate limiting errors (429) are NOT retryable. Applications must handle
70/// rate limits globally with proper coordination rather than retrying individual requests.
71///
72/// ## Examples
73///
74/// ```rust
75/// use odos_sdk::{OdosError, Result};
76/// use reqwest::StatusCode;
77///
78/// // Create different error types
79/// let api_error = OdosError::api_error(StatusCode::BAD_REQUEST, "Invalid input".to_string());
80/// let timeout_error = OdosError::timeout_error("Request timed out");
81/// let rate_limit_error = OdosError::rate_limit_error("Too many requests");
82///
83/// // Check if errors are retryable
84/// assert!(!api_error.is_retryable()); // 4xx errors are not retryable
85/// assert!(timeout_error.is_retryable()); // Timeouts are retryable
86/// assert!(!rate_limit_error.is_retryable()); // Rate limits are NOT retryable
87///
88/// // Get error categories for metrics
89/// assert_eq!(api_error.category(), "api");
90/// assert_eq!(timeout_error.category(), "timeout");
91/// assert_eq!(rate_limit_error.category(), "rate_limit");
92/// ```
93#[derive(Error, Debug)]
94pub enum OdosError {
95 /// HTTP request errors
96 #[error("HTTP request failed: {0}")]
97 Http(#[from] reqwest::Error),
98
99 /// API errors returned by the Odos service
100 #[error("Odos API error (status: {status}): {body}")]
101 Api {
102 status: StatusCode,
103 body: ApiErrorBody,
104 },
105
106 /// JSON serialization/deserialization errors
107 #[error("JSON processing error: {0}")]
108 Json(#[from] serde_json::Error),
109
110 /// Hex decoding errors
111 #[error("Hex decoding error: {0}")]
112 Hex(#[from] hex::FromHexError),
113
114 /// Invalid input parameters
115 #[error("Invalid input: {0}")]
116 InvalidInput(String),
117
118 /// Missing required data
119 #[error("Missing required data: {0}")]
120 MissingData(String),
121
122 /// Chain not supported
123 #[error("Chain not supported: {chain_id}")]
124 UnsupportedChain { chain_id: u64 },
125
126 /// Contract interaction errors
127 #[error("Contract error: {0}")]
128 Contract(String),
129
130 /// Transaction assembly errors
131 #[error("Transaction assembly failed: {0}")]
132 TransactionAssembly(String),
133
134 /// Quote request errors
135 #[error("Quote request failed: {0}")]
136 QuoteRequest(String),
137
138 /// Configuration errors
139 #[error("Configuration error: {0}")]
140 Configuration(String),
141
142 /// Timeout errors
143 #[error("Operation timed out: {0}")]
144 Timeout(String),
145
146 /// Rate limit exceeded
147 ///
148 /// Contains an optional `retry_after` duration from the Retry-After HTTP header,
149 /// alongside the shared [`ApiErrorBody`] (message, error code, and trace ID).
150 #[error("Rate limit exceeded: {body}")]
151 RateLimit {
152 retry_after: Option<Duration>,
153 body: ApiErrorBody,
154 },
155
156 /// Generic internal error
157 #[error("Internal error: {0}")]
158 Internal(String),
159}
160
161impl OdosError {
162 /// Create an API error from response (without error code or trace ID)
163 pub fn api_error(status: StatusCode, message: String) -> Self {
164 Self::api_error_with_code(status, message, OdosErrorCode::Unknown(0), None)
165 }
166
167 /// Create an API error with error code and trace ID
168 pub fn api_error_with_code(
169 status: StatusCode,
170 message: String,
171 code: OdosErrorCode,
172 trace_id: Option<TraceId>,
173 ) -> Self {
174 Self::Api {
175 status,
176 body: ApiErrorBody {
177 message,
178 code,
179 trace_id,
180 },
181 }
182 }
183
184 /// Create an invalid input error
185 pub fn invalid_input(message: impl Into<String>) -> Self {
186 Self::InvalidInput(message.into())
187 }
188
189 /// Create a missing data error
190 pub fn missing_data(message: impl Into<String>) -> Self {
191 Self::MissingData(message.into())
192 }
193
194 /// Create an unsupported chain error
195 pub fn unsupported_chain(chain_id: u64) -> Self {
196 Self::UnsupportedChain { chain_id }
197 }
198
199 /// Create a contract error
200 pub fn contract_error(message: impl Into<String>) -> Self {
201 Self::Contract(message.into())
202 }
203
204 /// Create a transaction assembly error
205 pub fn transaction_assembly_error(message: impl Into<String>) -> Self {
206 Self::TransactionAssembly(message.into())
207 }
208
209 /// Create a quote request error
210 pub fn quote_request_error(message: impl Into<String>) -> Self {
211 Self::QuoteRequest(message.into())
212 }
213
214 /// Create a configuration error
215 pub fn configuration_error(message: impl Into<String>) -> Self {
216 Self::Configuration(message.into())
217 }
218
219 /// Create a timeout error
220 pub fn timeout_error(message: impl Into<String>) -> Self {
221 Self::Timeout(message.into())
222 }
223
224 /// Create a rate limit error with optional retry-after duration
225 pub fn rate_limit_error(message: impl Into<String>) -> Self {
226 Self::rate_limit_error_with_retry_after(message, None)
227 }
228
229 /// Create a rate limit error with retry-after duration
230 pub fn rate_limit_error_with_retry_after(
231 message: impl Into<String>,
232 retry_after: Option<Duration>,
233 ) -> Self {
234 Self::rate_limit_error_with_retry_after_and_trace(
235 message,
236 retry_after,
237 OdosErrorCode::Unknown(429),
238 None,
239 )
240 }
241
242 /// Create a rate limit error with all fields
243 pub fn rate_limit_error_with_retry_after_and_trace(
244 message: impl Into<String>,
245 retry_after: Option<Duration>,
246 code: OdosErrorCode,
247 trace_id: Option<TraceId>,
248 ) -> Self {
249 Self::RateLimit {
250 retry_after,
251 body: ApiErrorBody {
252 message: message.into(),
253 code,
254 trace_id,
255 },
256 }
257 }
258
259 /// Create an internal error
260 pub fn internal_error(message: impl Into<String>) -> Self {
261 Self::Internal(message.into())
262 }
263
264 /// Check if the error is retryable
265 ///
266 /// For [`OdosError::Api`] errors, the typed [`OdosErrorCode`] is the
267 /// source of truth: a known code's [`OdosErrorCode::is_retryable`]
268 /// classification is honoured directly. Only [`OdosErrorCode::Unknown`]
269 /// falls back to the HTTP status code (500/502/503/504 → retryable).
270 ///
271 /// `OdosHttpClient::should_retry` consults this method for the default
272 /// retry policy, but client-side gates can take precedence:
273 /// - [`RetryPredicate::Replace`] overrides this method entirely.
274 /// - [`RetryPredicate::DefaultExcept`] vetoes retries for matched errors
275 /// while otherwise deferring to this method.
276 /// - `retry_server_errors=false` short-circuits any 5xx [`OdosError::Api`]
277 /// retry regardless of the typed classification (honoured for
278 /// `RetryPredicate::Default` and `DefaultExcept`).
279 ///
280 /// [`RetryPredicate::Replace`]: crate::RetryPredicate::Replace
281 /// [`RetryPredicate::DefaultExcept`]: crate::RetryPredicate::DefaultExcept
282 pub fn is_retryable(&self) -> bool {
283 match self {
284 OdosError::Http(err) => err.is_timeout() || err.is_connect() || err.is_request(),
285 OdosError::Api { status, body } => {
286 if matches!(body.code, OdosErrorCode::Unknown(_)) {
287 matches!(
288 *status,
289 StatusCode::INTERNAL_SERVER_ERROR
290 | StatusCode::BAD_GATEWAY
291 | StatusCode::SERVICE_UNAVAILABLE
292 | StatusCode::GATEWAY_TIMEOUT
293 )
294 } else {
295 body.code.is_retryable()
296 }
297 }
298 OdosError::Timeout(_) => true,
299 // NEVER retry rate limits - application must handle globally
300 OdosError::RateLimit { .. } => false,
301 OdosError::Json(_)
302 | OdosError::Hex(_)
303 | OdosError::InvalidInput(_)
304 | OdosError::MissingData(_)
305 | OdosError::UnsupportedChain { .. }
306 | OdosError::Contract(_)
307 | OdosError::TransactionAssembly(_)
308 | OdosError::QuoteRequest(_)
309 | OdosError::Configuration(_)
310 | OdosError::Internal(_) => false,
311 }
312 }
313
314 /// Check if this error is specifically a rate limit error
315 ///
316 /// This is a convenience method to help with error handling patterns.
317 /// Rate limit errors indicate that the Odos API has rejected the request
318 /// due to too many requests being made in a given time period.
319 ///
320 /// # Examples
321 ///
322 /// ```rust
323 /// use odos_sdk::{OdosError, OdosSor, QuoteRequest};
324 ///
325 /// # async fn example(client: &OdosSor, request: &QuoteRequest) {
326 /// match client.get_swap_quote(request).await {
327 /// Ok(quote) => { /* handle quote */ }
328 /// Err(e) if e.is_rate_limit() => {
329 /// // Specific handling for rate limits
330 /// eprintln!("Rate limited - consider backing off");
331 /// }
332 /// Err(e) => { /* handle other errors */ }
333 /// }
334 /// # }
335 /// ```
336 pub fn is_rate_limit(&self) -> bool {
337 matches!(self, OdosError::RateLimit { .. })
338 }
339
340 /// Get the retry-after duration for rate limit errors
341 ///
342 /// Returns `Some(duration)` if this is a rate limit error with a retry-after value,
343 /// `None` otherwise.
344 ///
345 /// # Examples
346 ///
347 /// ```rust
348 /// use odos_sdk::OdosError;
349 /// use std::time::Duration;
350 ///
351 /// let error = OdosError::rate_limit_error_with_retry_after(
352 /// "Rate limited",
353 /// Some(Duration::from_secs(30))
354 /// );
355 ///
356 /// if let Some(duration) = error.retry_after() {
357 /// println!("Retry after {} seconds", duration.as_secs());
358 /// }
359 /// ```
360 pub fn retry_after(&self) -> Option<Duration> {
361 match self {
362 OdosError::RateLimit { retry_after, .. } => *retry_after,
363 _ => None,
364 }
365 }
366
367 /// Get the Odos API error code if available
368 ///
369 /// Returns the strongly-typed error code for API and rate limit errors,
370 /// or `None` for other error types.
371 ///
372 /// # Examples
373 ///
374 /// ```rust
375 /// use odos_sdk::{OdosError, error_code::OdosErrorCode};
376 /// use reqwest::StatusCode;
377 ///
378 /// let error = OdosError::api_error_with_code(
379 /// StatusCode::BAD_REQUEST,
380 /// "Invalid chain ID".to_string(),
381 /// OdosErrorCode::from(4001),
382 /// None
383 /// );
384 ///
385 /// if let Some(code) = error.error_code() {
386 /// if code.is_invalid_chain_id() {
387 /// println!("Chain ID validation failed");
388 /// }
389 /// }
390 /// ```
391 pub fn error_code(&self) -> Option<&OdosErrorCode> {
392 self.api_error_body().map(|body| &body.code)
393 }
394
395 /// Get the Odos API trace ID if available
396 ///
397 /// Returns the trace ID for debugging API errors, or `None` for other error types
398 /// or if the trace ID was not included in the API response.
399 ///
400 /// # Examples
401 ///
402 /// ```rust
403 /// use odos_sdk::OdosError;
404 ///
405 /// # fn handle_error(error: &OdosError) {
406 /// if let Some(trace_id) = error.trace_id() {
407 /// eprintln!("Error trace ID for support: {}", trace_id);
408 /// }
409 /// # }
410 /// ```
411 pub fn trace_id(&self) -> Option<TraceId> {
412 self.api_error_body().and_then(|body| body.trace_id)
413 }
414
415 /// Borrow the shared payload that backs both API-shaped variants
416 /// ([`OdosError::Api`] and [`OdosError::RateLimit`]); returns `None`
417 /// for any other error.
418 pub fn api_error_body(&self) -> Option<&ApiErrorBody> {
419 match self {
420 OdosError::Api { body, .. } | OdosError::RateLimit { body, .. } => Some(body),
421 _ => None,
422 }
423 }
424
425 /// Get the error category for metrics
426 pub fn category(&self) -> &'static str {
427 match self {
428 OdosError::Http(_) => "http",
429 OdosError::Api { .. } => "api",
430 OdosError::Json(_) => "json",
431 OdosError::Hex(_) => "hex",
432 OdosError::InvalidInput(_) => "invalid_input",
433 OdosError::MissingData(_) => "missing_data",
434 OdosError::UnsupportedChain { .. } => "unsupported_chain",
435 OdosError::Contract(_) => "contract",
436 OdosError::TransactionAssembly(_) => "transaction_assembly",
437 OdosError::QuoteRequest(_) => "quote_request",
438 OdosError::Configuration(_) => "configuration",
439 OdosError::Timeout(_) => "timeout",
440 OdosError::RateLimit { .. } => "rate_limit",
441 OdosError::Internal(_) => "internal",
442 }
443 }
444
445 /// Get suggested retry delay for this error
446 ///
447 /// Returns a suggested delay before retrying the operation based on the error type:
448 /// - **Rate Limit**: Returns the `retry_after` value from the API if available,
449 /// otherwise suggests 60 seconds. Note: Rate limits should be handled at the
450 /// application level with proper coordination.
451 /// - **Timeout**: Suggests 1 second delay before retry
452 /// - **HTTP Server Errors (5xx)**: Suggests 2 seconds with exponential backoff
453 /// - **HTTP Connection Errors**: Suggests 500ms before retry
454 /// - **Non-retryable Errors**: Returns `None`
455 ///
456 /// # Examples
457 ///
458 /// ```rust
459 /// use odos_sdk::{OdosClient, QuoteRequest};
460 /// use std::time::Duration;
461 ///
462 /// # async fn example(client: &OdosClient, request: &QuoteRequest) -> Result<(), Box<dyn std::error::Error>> {
463 /// match client.quote(request).await {
464 /// Ok(quote) => { /* handle quote */ }
465 /// Err(e) => {
466 /// if let Some(delay) = e.suggested_retry_delay() {
467 /// println!("Retrying after {} seconds", delay.as_secs());
468 /// tokio::time::sleep(delay).await;
469 /// // Retry the operation...
470 /// } else {
471 /// println!("Error is not retryable: {}", e);
472 /// }
473 /// }
474 /// }
475 /// # Ok(())
476 /// # }
477 /// ```
478 pub fn suggested_retry_delay(&self) -> Option<Duration> {
479 match self {
480 // Rate limit - use retry_after if available, otherwise 60s
481 // Note: Rate limits should be handled globally, not per-request
482 OdosError::RateLimit { retry_after, .. } => {
483 Some(retry_after.unwrap_or(Duration::from_secs(60)))
484 }
485 // Timeout - short delay
486 OdosError::Timeout(_) => Some(Duration::from_secs(1)),
487 // API server errors - moderate delay
488 OdosError::Api { status, .. } if status.is_server_error() => {
489 Some(Duration::from_secs(2))
490 }
491 // HTTP errors - depends on error type
492 OdosError::Http(err) => {
493 if err.is_timeout() {
494 Some(Duration::from_secs(1))
495 } else if err.is_connect() || err.is_request() {
496 Some(Duration::from_millis(500))
497 } else {
498 None
499 }
500 }
501 // All other errors are not retryable
502 _ => None,
503 }
504 }
505
506 /// Check if this is a client error (4xx status code)
507 ///
508 /// Returns `true` if this is an API error with a 4xx status code,
509 /// indicating that the request was invalid and should not be retried
510 /// without modification.
511 ///
512 /// # Examples
513 ///
514 /// ```rust
515 /// use odos_sdk::OdosError;
516 /// use reqwest::StatusCode;
517 ///
518 /// let error = OdosError::api_error(
519 /// StatusCode::BAD_REQUEST,
520 /// "Invalid chain ID".to_string()
521 /// );
522 ///
523 /// assert!(error.is_client_error());
524 /// assert!(!error.is_retryable());
525 /// ```
526 pub fn is_client_error(&self) -> bool {
527 matches!(self, OdosError::Api { status, .. } if status.is_client_error())
528 }
529
530 /// Check if this is a server error (5xx status code)
531 ///
532 /// Returns `true` if this is an API error with a 5xx status code,
533 /// indicating a server-side problem that may be resolved by retrying.
534 ///
535 /// # Examples
536 ///
537 /// ```rust
538 /// use odos_sdk::OdosError;
539 /// use reqwest::StatusCode;
540 ///
541 /// let error = OdosError::api_error(
542 /// StatusCode::INTERNAL_SERVER_ERROR,
543 /// "Server error".to_string()
544 /// );
545 ///
546 /// assert!(error.is_server_error());
547 /// assert!(error.is_retryable());
548 /// ```
549 pub fn is_server_error(&self) -> bool {
550 matches!(self, OdosError::Api { status, .. } if status.is_server_error())
551 }
552}
553
554// Convert chain errors to appropriate error types
555impl From<OdosChainError> for OdosError {
556 fn from(err: OdosChainError) -> Self {
557 match err {
558 OdosChainError::LimitOrderNotAvailable { chain } => Self::contract_error(format!(
559 "Limit Order router not available on chain: {chain}"
560 )),
561 OdosChainError::V2NotAvailable { chain } => {
562 Self::contract_error(format!("V2 router not available on chain: {chain}"))
563 }
564 OdosChainError::V3NotAvailable { chain } => {
565 Self::contract_error(format!("V3 router not available on chain: {chain}"))
566 }
567 OdosChainError::UnsupportedChain { chain } => {
568 Self::contract_error(format!("Unsupported chain: {chain}"))
569 }
570 OdosChainError::InvalidAddress { address } => {
571 Self::invalid_input(format!("Invalid address format: {address}"))
572 }
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use reqwest::StatusCode;
581
582 #[test]
583 fn test_retryable_errors() {
584 // HTTP timeout should be retryable
585 let timeout_err = OdosError::timeout_error("Request timed out");
586 assert!(timeout_err.is_retryable());
587
588 // API 500 error should be retryable
589 let api_err = OdosError::api_error(
590 StatusCode::INTERNAL_SERVER_ERROR,
591 "Server error".to_string(),
592 );
593 assert!(api_err.is_retryable());
594
595 // Invalid input should not be retryable
596 let invalid_err = OdosError::invalid_input("Bad parameter");
597 assert!(!invalid_err.is_retryable());
598
599 // Rate limit should NOT be retryable (application must handle globally)
600 let rate_limit_err = OdosError::rate_limit_error("Too many requests");
601 assert!(!rate_limit_err.is_retryable());
602 }
603
604 #[test]
605 fn test_error_categories() {
606 let api_err = OdosError::api_error(StatusCode::BAD_REQUEST, "Bad request".to_string());
607 assert_eq!(api_err.category(), "api");
608
609 let timeout_err = OdosError::timeout_error("Timeout");
610 assert_eq!(timeout_err.category(), "timeout");
611
612 let invalid_err = OdosError::invalid_input("Invalid");
613 assert_eq!(invalid_err.category(), "invalid_input");
614 }
615
616 #[test]
617 fn test_suggested_retry_delay() {
618 // Rate limit with retry-after
619 let rate_limit_with_retry = OdosError::rate_limit_error_with_retry_after(
620 "Rate limited",
621 Some(Duration::from_secs(30)),
622 );
623 assert_eq!(
624 rate_limit_with_retry.suggested_retry_delay(),
625 Some(Duration::from_secs(30))
626 );
627
628 // Rate limit without retry-after (defaults to 60s)
629 let rate_limit_no_retry = OdosError::rate_limit_error("Rate limited");
630 assert_eq!(
631 rate_limit_no_retry.suggested_retry_delay(),
632 Some(Duration::from_secs(60))
633 );
634
635 // Timeout error
636 let timeout_err = OdosError::timeout_error("Timeout");
637 assert_eq!(
638 timeout_err.suggested_retry_delay(),
639 Some(Duration::from_secs(1))
640 );
641
642 // Server error
643 let server_err = OdosError::api_error(
644 StatusCode::INTERNAL_SERVER_ERROR,
645 "Server error".to_string(),
646 );
647 assert_eq!(
648 server_err.suggested_retry_delay(),
649 Some(Duration::from_secs(2))
650 );
651
652 // Client error (not retryable)
653 let client_err = OdosError::api_error(StatusCode::BAD_REQUEST, "Bad request".to_string());
654 assert_eq!(client_err.suggested_retry_delay(), None);
655
656 // Invalid input (not retryable)
657 let invalid_err = OdosError::invalid_input("Invalid");
658 assert_eq!(invalid_err.suggested_retry_delay(), None);
659 }
660
661 #[test]
662 fn test_client_and_server_errors() {
663 // Client error
664 let client_err = OdosError::api_error(StatusCode::BAD_REQUEST, "Bad request".to_string());
665 assert!(client_err.is_client_error());
666 assert!(!client_err.is_server_error());
667
668 // Server error
669 let server_err = OdosError::api_error(
670 StatusCode::INTERNAL_SERVER_ERROR,
671 "Server error".to_string(),
672 );
673 assert!(!server_err.is_client_error());
674 assert!(server_err.is_server_error());
675
676 // Non-API error
677 let other_err = OdosError::invalid_input("Invalid");
678 assert!(!other_err.is_client_error());
679 assert!(!other_err.is_server_error());
680 }
681}