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}