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