odos_sdk/
error.rs

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