odos_sdk/
error.rs

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