Skip to main content

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}