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}