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: Option<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    /// which indicates how long to wait before making another request.
117    #[error("Rate limit exceeded: {message}")]
118    RateLimit {
119        message: String,
120        retry_after: Option<Duration>,
121    },
122
123    /// Generic internal error
124    #[error("Internal error: {0}")]
125    Internal(String),
126}
127
128impl OdosError {
129    /// Create an API error from response (without error code or trace ID)
130    pub fn api_error(status: StatusCode, message: String) -> Self {
131        Self::Api {
132            status,
133            message,
134            code: None,
135            trace_id: None,
136        }
137    }
138
139    /// Create an API error with error code and trace ID
140    pub fn api_error_with_code(
141        status: StatusCode,
142        message: String,
143        code: Option<OdosErrorCode>,
144        trace_id: Option<TraceId>,
145    ) -> Self {
146        Self::Api {
147            status,
148            message,
149            code,
150            trace_id,
151        }
152    }
153
154    /// Create an invalid input error
155    pub fn invalid_input(message: impl Into<String>) -> Self {
156        Self::InvalidInput(message.into())
157    }
158
159    /// Create a missing data error
160    pub fn missing_data(message: impl Into<String>) -> Self {
161        Self::MissingData(message.into())
162    }
163
164    /// Create an unsupported chain error
165    pub fn unsupported_chain(chain_id: u64) -> Self {
166        Self::UnsupportedChain { chain_id }
167    }
168
169    /// Create a contract error
170    pub fn contract_error(message: impl Into<String>) -> Self {
171        Self::Contract(message.into())
172    }
173
174    /// Create a transaction assembly error
175    pub fn transaction_assembly_error(message: impl Into<String>) -> Self {
176        Self::TransactionAssembly(message.into())
177    }
178
179    /// Create a quote request error
180    pub fn quote_request_error(message: impl Into<String>) -> Self {
181        Self::QuoteRequest(message.into())
182    }
183
184    /// Create a configuration error
185    pub fn configuration_error(message: impl Into<String>) -> Self {
186        Self::Configuration(message.into())
187    }
188
189    /// Create a timeout error
190    pub fn timeout_error(message: impl Into<String>) -> Self {
191        Self::Timeout(message.into())
192    }
193
194    /// Create a rate limit error with optional retry-after duration
195    pub fn rate_limit_error(message: impl Into<String>) -> Self {
196        Self::RateLimit {
197            message: message.into(),
198            retry_after: None,
199        }
200    }
201
202    /// Create a rate limit error with retry-after duration
203    pub fn rate_limit_error_with_retry_after(
204        message: impl Into<String>,
205        retry_after: Option<Duration>,
206    ) -> Self {
207        Self::RateLimit {
208            message: message.into(),
209            retry_after,
210        }
211    }
212
213    /// Create an internal error
214    pub fn internal_error(message: impl Into<String>) -> Self {
215        Self::Internal(message.into())
216    }
217
218    /// Check if the error is retryable
219    ///
220    /// For API errors with error codes, the retryability is determined by the error code.
221    /// For API errors without error codes, falls back to HTTP status code checking.
222    pub fn is_retryable(&self) -> bool {
223        match self {
224            // HTTP errors that are typically retryable
225            OdosError::Http(err) => {
226                // Timeout, connection errors, etc.
227                err.is_timeout() || err.is_connect() || err.is_request()
228            }
229            // API errors - check error code first, then status code
230            OdosError::Api { status, code, .. } => {
231                // If we have an error code, use its retryability logic
232                if let Some(error_code) = code {
233                    error_code.is_retryable()
234                } else {
235                    // Fall back to status code checking
236                    matches!(
237                        *status,
238                        StatusCode::TOO_MANY_REQUESTS
239                            | StatusCode::INTERNAL_SERVER_ERROR
240                            | StatusCode::BAD_GATEWAY
241                            | StatusCode::SERVICE_UNAVAILABLE
242                            | StatusCode::GATEWAY_TIMEOUT
243                    )
244                }
245            }
246            // Other retryable errors
247            OdosError::Timeout(_) => true,
248            // NEVER retry rate limits - application must handle globally
249            OdosError::RateLimit { .. } => false,
250            // Non-retryable errors
251            OdosError::Json(_)
252            | OdosError::Hex(_)
253            | OdosError::InvalidInput(_)
254            | OdosError::MissingData(_)
255            | OdosError::UnsupportedChain { .. }
256            | OdosError::Contract(_)
257            | OdosError::TransactionAssembly(_)
258            | OdosError::QuoteRequest(_)
259            | OdosError::Configuration(_)
260            | OdosError::Internal(_) => false,
261        }
262    }
263
264    /// Check if this error is specifically a rate limit error
265    ///
266    /// This is a convenience method to help with error handling patterns.
267    /// Rate limit errors indicate that the Odos API has rejected the request
268    /// due to too many requests being made in a given time period.
269    ///
270    /// # Examples
271    ///
272    /// ```rust
273    /// use odos_sdk::{OdosError, OdosSorV2, QuoteRequest};
274    ///
275    /// # async fn example(client: &OdosSorV2, request: &QuoteRequest) {
276    /// match client.get_swap_quote(request).await {
277    ///     Ok(quote) => { /* handle quote */ }
278    ///     Err(e) if e.is_rate_limit() => {
279    ///         // Specific handling for rate limits
280    ///         eprintln!("Rate limited - consider backing off");
281    ///     }
282    ///     Err(e) => { /* handle other errors */ }
283    /// }
284    /// # }
285    /// ```
286    pub fn is_rate_limit(&self) -> bool {
287        matches!(self, OdosError::RateLimit { .. })
288    }
289
290    /// Get the retry-after duration for rate limit errors
291    ///
292    /// Returns `Some(duration)` if this is a rate limit error with a retry-after value,
293    /// `None` otherwise.
294    ///
295    /// # Examples
296    ///
297    /// ```rust
298    /// use odos_sdk::OdosError;
299    /// use std::time::Duration;
300    ///
301    /// let error = OdosError::rate_limit_error_with_retry_after(
302    ///     "Rate limited",
303    ///     Some(Duration::from_secs(30))
304    /// );
305    ///
306    /// if let Some(duration) = error.retry_after() {
307    ///     println!("Retry after {} seconds", duration.as_secs());
308    /// }
309    /// ```
310    pub fn retry_after(&self) -> Option<Duration> {
311        match self {
312            OdosError::RateLimit { retry_after, .. } => *retry_after,
313            _ => None,
314        }
315    }
316
317    /// Get the Odos API error code if available
318    ///
319    /// Returns the strongly-typed error code for API errors, or `None` for other error types
320    /// or if the error code was not included in the API response.
321    ///
322    /// # Examples
323    ///
324    /// ```rust
325    /// use odos_sdk::{OdosError, error_code::OdosErrorCode};
326    /// use reqwest::StatusCode;
327    ///
328    /// let error = OdosError::api_error_with_code(
329    ///     StatusCode::BAD_REQUEST,
330    ///     "Invalid chain ID".to_string(),
331    ///     Some(OdosErrorCode::from(4001)),
332    ///     None
333    /// );
334    ///
335    /// if let Some(code) = error.error_code() {
336    ///     if code.is_invalid_chain_id() {
337    ///         println!("Chain ID validation failed");
338    ///     }
339    /// }
340    /// ```
341    pub fn error_code(&self) -> Option<&OdosErrorCode> {
342        match self {
343            OdosError::Api { code, .. } => code.as_ref(),
344            _ => None,
345        }
346    }
347
348    /// Get the Odos API trace ID if available
349    ///
350    /// Returns the trace ID for debugging API errors, or `None` for other error types
351    /// or if the trace ID was not included in the API response.
352    ///
353    /// # Examples
354    ///
355    /// ```rust
356    /// use odos_sdk::OdosError;
357    ///
358    /// # fn handle_error(error: &OdosError) {
359    /// if let Some(trace_id) = error.trace_id() {
360    ///     eprintln!("Error trace ID for support: {}", trace_id);
361    /// }
362    /// # }
363    /// ```
364    pub fn trace_id(&self) -> Option<TraceId> {
365        match self {
366            OdosError::Api { trace_id, .. } => *trace_id,
367            _ => None,
368        }
369    }
370
371    /// Get the error category for metrics
372    pub fn category(&self) -> &'static str {
373        match self {
374            OdosError::Http(_) => "http",
375            OdosError::Api { .. } => "api",
376            OdosError::Json(_) => "json",
377            OdosError::Hex(_) => "hex",
378            OdosError::InvalidInput(_) => "invalid_input",
379            OdosError::MissingData(_) => "missing_data",
380            OdosError::UnsupportedChain { .. } => "unsupported_chain",
381            OdosError::Contract(_) => "contract",
382            OdosError::TransactionAssembly(_) => "transaction_assembly",
383            OdosError::QuoteRequest(_) => "quote_request",
384            OdosError::Configuration(_) => "configuration",
385            OdosError::Timeout(_) => "timeout",
386            OdosError::RateLimit { .. } => "rate_limit",
387            OdosError::Internal(_) => "internal",
388        }
389    }
390}
391
392// Compatibility with anyhow for gradual migration
393impl From<anyhow::Error> for OdosError {
394    fn from(err: anyhow::Error) -> Self {
395        Self::Internal(err.to_string())
396    }
397}
398
399// Convert chain errors to appropriate error types
400impl From<OdosChainError> for OdosError {
401    fn from(err: OdosChainError) -> Self {
402        match err {
403            OdosChainError::V2NotAvailable { chain } => {
404                Self::contract_error(format!("V2 router not available on chain: {chain}"))
405            }
406            OdosChainError::V3NotAvailable { chain } => {
407                Self::contract_error(format!("V3 router not available on chain: {chain}"))
408            }
409            OdosChainError::UnsupportedChain { chain } => {
410                Self::contract_error(format!("Unsupported chain: {chain}"))
411            }
412            OdosChainError::InvalidAddress { address } => {
413                Self::invalid_input(format!("Invalid address format: {address}"))
414            }
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use reqwest::StatusCode;
423
424    #[test]
425    fn test_retryable_errors() {
426        // HTTP timeout should be retryable
427        let timeout_err = OdosError::timeout_error("Request timed out");
428        assert!(timeout_err.is_retryable());
429
430        // API 500 error should be retryable
431        let api_err = OdosError::api_error(
432            StatusCode::INTERNAL_SERVER_ERROR,
433            "Server error".to_string(),
434        );
435        assert!(api_err.is_retryable());
436
437        // Invalid input should not be retryable
438        let invalid_err = OdosError::invalid_input("Bad parameter");
439        assert!(!invalid_err.is_retryable());
440
441        // Rate limit should NOT be retryable (application must handle globally)
442        let rate_limit_err = OdosError::rate_limit_error("Too many requests");
443        assert!(!rate_limit_err.is_retryable());
444    }
445
446    #[test]
447    fn test_error_categories() {
448        let api_err = OdosError::api_error(StatusCode::BAD_REQUEST, "Bad request".to_string());
449        assert_eq!(api_err.category(), "api");
450
451        let timeout_err = OdosError::timeout_error("Timeout");
452        assert_eq!(timeout_err.category(), "timeout");
453
454        let invalid_err = OdosError::invalid_input("Invalid");
455        assert_eq!(invalid_err.category(), "invalid_input");
456    }
457}