Skip to main content

fynd_client/
error.rs

1use thiserror::Error;
2
3/// A structured error code returned by the Fynd RPC API.
4///
5/// Mapped from the raw string `code` field in
6/// [`fynd_rpc_types::ErrorResponse`].
7#[derive(Debug, Clone, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum ErrorCode {
10    /// The request was malformed or contained invalid parameters.
11    ///
12    /// Server codes: `BAD_REQUEST`, `INVALID_ORDER`.
13    BadRequest,
14
15    /// No swap route exists between the requested token pair.
16    ///
17    /// Server code: `NO_ROUTE_FOUND`.
18    NoRouteFound,
19
20    /// A route exists but available component liquidity is too shallow for the
21    /// requested amount.
22    ///
23    /// Server code: `INSUFFICIENT_LIQUIDITY`.
24    InsufficientLiquidity,
25
26    /// The solver timed out before returning a route. Retrying may succeed.
27    ///
28    /// Server code: `TIMEOUT`.
29    SolveTimeout,
30
31    /// The server is temporarily unavailable (overloaded, queue full, stale data, or not yet
32    /// initialised). Retrying after a short backoff should succeed.
33    ///
34    /// Server codes: `QUEUE_FULL`, `SERVICE_OVERLOADED`, `STALE_DATA`, `NOT_READY`.
35    ServiceUnavailable,
36
37    /// The server encountered an internal error processing the request. Not retryable.
38    ///
39    /// Server codes: `ALGORITHM_ERROR`, `INTERNAL_ERROR`, `FAILED_ENCODING`.
40    ServerError,
41
42    /// The requested endpoint does not exist. Indicates a client misconfiguration.
43    ///
44    /// Server code: `NOT_FOUND`.
45    NotFound,
46
47    /// A truly unrecognised server error code. The raw string is preserved for debugging.
48    Unknown(String),
49}
50
51impl ErrorCode {
52    /// Map a raw server error code string to a typed [`ErrorCode`].
53    ///
54    /// Unknown codes are wrapped in [`ErrorCode::Unknown`] rather than panicking.
55    pub fn from_server_code(code: &str) -> Self {
56        match code {
57            "BAD_REQUEST" | "INVALID_ORDER" => Self::BadRequest,
58            "NO_ROUTE_FOUND" => Self::NoRouteFound,
59            "INSUFFICIENT_LIQUIDITY" => Self::InsufficientLiquidity,
60            "TIMEOUT" => Self::SolveTimeout,
61            "QUEUE_FULL" | "SERVICE_OVERLOADED" | "STALE_DATA" | "NOT_READY" => {
62                Self::ServiceUnavailable
63            }
64            "ALGORITHM_ERROR" | "INTERNAL_ERROR" | "FAILED_ENCODING" | "PRICE_CHECK_FAILED" => {
65                Self::ServerError
66            }
67            "NOT_FOUND" => Self::NotFound,
68            other => Self::Unknown(other.to_string()),
69        }
70    }
71
72    /// Returns `true` if this error code indicates that the request is safe to retry.
73    ///
74    /// Only [`SolveTimeout`](Self::SolveTimeout) and
75    /// [`ServiceUnavailable`](Self::ServiceUnavailable) are retryable; all other codes
76    /// represent permanent failures.
77    pub fn is_retryable(&self) -> bool {
78        matches!(self, Self::SolveTimeout | Self::ServiceUnavailable)
79    }
80}
81
82/// Errors that can be returned by [`FyndClient`](crate::FyndClient) methods.
83#[derive(Debug, Error)]
84pub enum FyndError {
85    /// An HTTP-level error from the underlying `reqwest` client (network failure, timeout, etc.).
86    ///
87    /// HTTP errors are always considered retryable.
88    #[error("HTTP error: {0}")]
89    Http(#[from] reqwest::Error),
90
91    /// An error returned by the Ethereum JSON-RPC provider (e.g. during nonce/fee estimation or
92    /// transaction submission).
93    #[error("provider error: {0}")]
94    Provider(#[from] alloy::transports::RpcError<alloy::transports::TransportErrorKind>),
95
96    /// A structured error response from the Fynd RPC API. Check `code` to distinguish permanent
97    /// failures (e.g. `NoRouteFound`) from transient ones (e.g. `SolveTimeout`).
98    #[error("API error ({code:?}): {message}")]
99    Api {
100        /// The structured error code identifying the failure kind.
101        code: ErrorCode,
102        /// The human-readable error message returned by the server.
103        message: String,
104    },
105
106    /// Malformed or unexpected data in the API response (e.g. an address with the wrong byte
107    /// length, an unrecognised enum variant).
108    #[error("protocol error: {0}")]
109    Protocol(String),
110
111    /// A `eth_call` simulation of the swap transaction reverted. The message contains the
112    /// revert reason when available.
113    #[error("simulation failed: {0}")]
114    SimulationFailed(String),
115
116    /// An on-chain transaction was mined but reverted. The message contains the revert reason
117    /// decoded from replaying the transaction via `eth_call`.
118    #[error("transaction reverted: {0}")]
119    TransactionReverted(String),
120
121    /// Invalid client configuration (e.g. unparseable URL, missing sender address).
122    #[error("configuration error: {0}")]
123    Config(String),
124}
125
126impl FyndError {
127    /// Returns `true` if the operation that produced this error can safely be retried.
128    ///
129    /// HTTP errors and certain API error codes are retryable. Protocol, config, and simulation
130    /// errors are not.
131    pub fn is_retryable(&self) -> bool {
132        match self {
133            Self::Http(_) => true,
134            Self::Api { code, .. } => code.is_retryable(),
135            _ => false,
136        }
137    }
138
139    /// Returns `true` if this error represents an on-chain transaction revert.
140    pub fn is_revert(&self) -> bool {
141        matches!(self, Self::TransactionReverted(_))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn error_code_from_known_server_codes() {
151        assert_eq!(ErrorCode::from_server_code("BAD_REQUEST"), ErrorCode::BadRequest);
152        assert_eq!(ErrorCode::from_server_code("NO_ROUTE_FOUND"), ErrorCode::NoRouteFound);
153        assert_eq!(
154            ErrorCode::from_server_code("INSUFFICIENT_LIQUIDITY"),
155            ErrorCode::InsufficientLiquidity
156        );
157        assert_eq!(ErrorCode::from_server_code("INVALID_ORDER"), ErrorCode::BadRequest);
158        assert_eq!(ErrorCode::from_server_code("TIMEOUT"), ErrorCode::SolveTimeout);
159        assert_eq!(ErrorCode::from_server_code("QUEUE_FULL"), ErrorCode::ServiceUnavailable);
160        assert_eq!(
161            ErrorCode::from_server_code("SERVICE_OVERLOADED"),
162            ErrorCode::ServiceUnavailable
163        );
164        assert_eq!(ErrorCode::from_server_code("STALE_DATA"), ErrorCode::ServiceUnavailable);
165        assert_eq!(ErrorCode::from_server_code("NOT_READY"), ErrorCode::ServiceUnavailable);
166    }
167
168    #[test]
169    fn error_code_server_error_for_server_fault_codes() {
170        assert_eq!(ErrorCode::from_server_code("ALGORITHM_ERROR"), ErrorCode::ServerError);
171        assert_eq!(ErrorCode::from_server_code("INTERNAL_ERROR"), ErrorCode::ServerError);
172        assert_eq!(ErrorCode::from_server_code("FAILED_ENCODING"), ErrorCode::ServerError);
173    }
174
175    #[test]
176    fn error_code_not_found_for_not_found_code() {
177        assert_eq!(ErrorCode::from_server_code("NOT_FOUND"), ErrorCode::NotFound);
178    }
179
180    #[test]
181    fn error_code_unknown_for_unrecognised_codes() {
182        assert!(matches!(ErrorCode::from_server_code("WHATEVER"), ErrorCode::Unknown(_)));
183        assert!(matches!(ErrorCode::from_server_code("SOME_FUTURE_CODE"), ErrorCode::Unknown(_)));
184    }
185
186    #[test]
187    fn is_retryable_true_for_retryable_codes() {
188        assert!(
189            FyndError::Api { code: ErrorCode::SolveTimeout, message: String::new() }.is_retryable()
190        );
191        assert!(FyndError::Api { code: ErrorCode::ServiceUnavailable, message: String::new() }
192            .is_retryable());
193    }
194
195    #[test]
196    fn is_retryable_false_for_non_retryable_errors() {
197        assert!(
198            !FyndError::Api { code: ErrorCode::BadRequest, message: String::new() }.is_retryable()
199        );
200        assert!(!FyndError::Api { code: ErrorCode::NoRouteFound, message: String::new() }
201            .is_retryable());
202        assert!(!FyndError::Protocol("bad data".into()).is_retryable());
203        assert!(!FyndError::Config("missing sender".into()).is_retryable());
204    }
205}