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" => Self::ServerError,
64            "NOT_FOUND" => Self::NotFound,
65            other => Self::Unknown(other.to_string()),
66        }
67    }
68
69    /// Returns `true` if this error code indicates that the request is safe to retry.
70    ///
71    /// Only [`SolveTimeout`](Self::SolveTimeout) and
72    /// [`ServiceUnavailable`](Self::ServiceUnavailable) are retryable; all other codes
73    /// represent permanent failures.
74    pub fn is_retryable(&self) -> bool {
75        matches!(self, Self::SolveTimeout | Self::ServiceUnavailable)
76    }
77}
78
79/// Errors that can be returned by [`FyndClient`](crate::FyndClient) methods.
80#[derive(Debug, Error)]
81pub enum FyndError {
82    /// An HTTP-level error from the underlying `reqwest` client (network failure, timeout, etc.).
83    ///
84    /// HTTP errors are always considered retryable.
85    #[error("HTTP error: {0}")]
86    Http(#[from] reqwest::Error),
87
88    /// An error returned by the Ethereum JSON-RPC provider (e.g. during nonce/fee estimation or
89    /// transaction submission).
90    #[error("provider error: {0}")]
91    Provider(#[from] alloy::transports::RpcError<alloy::transports::TransportErrorKind>),
92
93    /// A structured error response from the Fynd RPC API. Check `code` to distinguish permanent
94    /// failures (e.g. `NoRouteFound`) from transient ones (e.g. `SolveTimeout`).
95    #[error("API error ({code:?}): {message}")]
96    Api {
97        /// The structured error code identifying the failure kind.
98        code: ErrorCode,
99        /// The human-readable error message returned by the server.
100        message: String,
101    },
102
103    /// Malformed or unexpected data in the API response (e.g. an address with the wrong byte
104    /// length, an unrecognised enum variant).
105    #[error("protocol error: {0}")]
106    Protocol(String),
107
108    /// A `eth_call` simulation of the swap transaction reverted. The message contains the
109    /// revert reason when available.
110    #[error("simulation failed: {0}")]
111    SimulationFailed(String),
112
113    /// An on-chain transaction was mined but reverted. The message contains the revert reason
114    /// decoded from replaying the transaction via `eth_call`.
115    #[error("transaction reverted: {0}")]
116    TransactionReverted(String),
117
118    /// Invalid client configuration (e.g. unparseable URL, missing sender address).
119    #[error("configuration error: {0}")]
120    Config(String),
121}
122
123impl FyndError {
124    /// Returns `true` if the operation that produced this error can safely be retried.
125    ///
126    /// HTTP errors and certain API error codes are retryable. Protocol, config, and simulation
127    /// errors are not.
128    pub fn is_retryable(&self) -> bool {
129        match self {
130            Self::Http(_) => true,
131            Self::Api { code, .. } => code.is_retryable(),
132            _ => false,
133        }
134    }
135
136    /// Returns `true` if this error represents an on-chain transaction revert.
137    pub fn is_revert(&self) -> bool {
138        matches!(self, Self::TransactionReverted(_))
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn error_code_from_known_server_codes() {
148        assert_eq!(ErrorCode::from_server_code("BAD_REQUEST"), ErrorCode::BadRequest);
149        assert_eq!(ErrorCode::from_server_code("NO_ROUTE_FOUND"), ErrorCode::NoRouteFound);
150        assert_eq!(
151            ErrorCode::from_server_code("INSUFFICIENT_LIQUIDITY"),
152            ErrorCode::InsufficientLiquidity
153        );
154        assert_eq!(ErrorCode::from_server_code("INVALID_ORDER"), ErrorCode::BadRequest);
155        assert_eq!(ErrorCode::from_server_code("TIMEOUT"), ErrorCode::SolveTimeout);
156        assert_eq!(ErrorCode::from_server_code("QUEUE_FULL"), ErrorCode::ServiceUnavailable);
157        assert_eq!(
158            ErrorCode::from_server_code("SERVICE_OVERLOADED"),
159            ErrorCode::ServiceUnavailable
160        );
161        assert_eq!(ErrorCode::from_server_code("STALE_DATA"), ErrorCode::ServiceUnavailable);
162        assert_eq!(ErrorCode::from_server_code("NOT_READY"), ErrorCode::ServiceUnavailable);
163    }
164
165    #[test]
166    fn error_code_server_error_for_server_fault_codes() {
167        assert_eq!(ErrorCode::from_server_code("ALGORITHM_ERROR"), ErrorCode::ServerError);
168        assert_eq!(ErrorCode::from_server_code("INTERNAL_ERROR"), ErrorCode::ServerError);
169        assert_eq!(ErrorCode::from_server_code("FAILED_ENCODING"), ErrorCode::ServerError);
170    }
171
172    #[test]
173    fn error_code_not_found_for_not_found_code() {
174        assert_eq!(ErrorCode::from_server_code("NOT_FOUND"), ErrorCode::NotFound);
175    }
176
177    #[test]
178    fn error_code_unknown_for_unrecognised_codes() {
179        assert!(matches!(ErrorCode::from_server_code("WHATEVER"), ErrorCode::Unknown(_)));
180        assert!(matches!(ErrorCode::from_server_code("SOME_FUTURE_CODE"), ErrorCode::Unknown(_)));
181    }
182
183    #[test]
184    fn is_retryable_true_for_retryable_codes() {
185        assert!(
186            FyndError::Api { code: ErrorCode::SolveTimeout, message: String::new() }.is_retryable()
187        );
188        assert!(FyndError::Api { code: ErrorCode::ServiceUnavailable, message: String::new() }
189            .is_retryable());
190    }
191
192    #[test]
193    fn is_retryable_false_for_non_retryable_errors() {
194        assert!(
195            !FyndError::Api { code: ErrorCode::BadRequest, message: String::new() }.is_retryable()
196        );
197        assert!(!FyndError::Api { code: ErrorCode::NoRouteFound, message: String::new() }
198            .is_retryable());
199        assert!(!FyndError::Protocol("bad data".into()).is_retryable());
200        assert!(!FyndError::Config("missing sender".into()).is_retryable());
201    }
202}