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}