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 { code: ErrorCode, message: String },
97
98    /// Malformed or unexpected data in the API response (e.g. an address with the wrong byte
99    /// length, an unrecognised enum variant).
100    #[error("protocol error: {0}")]
101    Protocol(String),
102
103    /// A `eth_call` simulation of the swap transaction reverted. The message contains the
104    /// revert reason when available.
105    #[error("simulation failed: {0}")]
106    SimulationFailed(String),
107
108    /// An on-chain transaction was mined but reverted. The message contains the revert reason
109    /// decoded from replaying the transaction via `eth_call`.
110    #[error("transaction reverted: {0}")]
111    TransactionReverted(String),
112
113    /// Invalid client configuration (e.g. unparseable URL, missing sender address).
114    #[error("configuration error: {0}")]
115    Config(String),
116}
117
118impl FyndError {
119    /// Returns `true` if the operation that produced this error can safely be retried.
120    ///
121    /// HTTP errors and certain API error codes are retryable. Protocol, config, and simulation
122    /// errors are not.
123    pub fn is_retryable(&self) -> bool {
124        match self {
125            Self::Http(_) => true,
126            Self::Api { code, .. } => code.is_retryable(),
127            _ => false,
128        }
129    }
130
131    /// Returns `true` if this error represents an on-chain transaction revert.
132    pub fn is_revert(&self) -> bool {
133        matches!(self, Self::TransactionReverted(_))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn error_code_from_known_server_codes() {
143        assert_eq!(ErrorCode::from_server_code("BAD_REQUEST"), ErrorCode::BadRequest);
144        assert_eq!(ErrorCode::from_server_code("NO_ROUTE_FOUND"), ErrorCode::NoRouteFound);
145        assert_eq!(
146            ErrorCode::from_server_code("INSUFFICIENT_LIQUIDITY"),
147            ErrorCode::InsufficientLiquidity
148        );
149        assert_eq!(ErrorCode::from_server_code("INVALID_ORDER"), ErrorCode::BadRequest);
150        assert_eq!(ErrorCode::from_server_code("TIMEOUT"), ErrorCode::SolveTimeout);
151        assert_eq!(ErrorCode::from_server_code("QUEUE_FULL"), ErrorCode::ServiceUnavailable);
152        assert_eq!(
153            ErrorCode::from_server_code("SERVICE_OVERLOADED"),
154            ErrorCode::ServiceUnavailable
155        );
156        assert_eq!(ErrorCode::from_server_code("STALE_DATA"), ErrorCode::ServiceUnavailable);
157        assert_eq!(ErrorCode::from_server_code("NOT_READY"), ErrorCode::ServiceUnavailable);
158    }
159
160    #[test]
161    fn error_code_server_error_for_server_fault_codes() {
162        assert_eq!(ErrorCode::from_server_code("ALGORITHM_ERROR"), ErrorCode::ServerError);
163        assert_eq!(ErrorCode::from_server_code("INTERNAL_ERROR"), ErrorCode::ServerError);
164        assert_eq!(ErrorCode::from_server_code("FAILED_ENCODING"), ErrorCode::ServerError);
165    }
166
167    #[test]
168    fn error_code_not_found_for_not_found_code() {
169        assert_eq!(ErrorCode::from_server_code("NOT_FOUND"), ErrorCode::NotFound);
170    }
171
172    #[test]
173    fn error_code_unknown_for_unrecognised_codes() {
174        assert!(matches!(ErrorCode::from_server_code("WHATEVER"), ErrorCode::Unknown(_)));
175        assert!(matches!(ErrorCode::from_server_code("SOME_FUTURE_CODE"), ErrorCode::Unknown(_)));
176    }
177
178    #[test]
179    fn is_retryable_true_for_retryable_codes() {
180        assert!(
181            FyndError::Api { code: ErrorCode::SolveTimeout, message: String::new() }.is_retryable()
182        );
183        assert!(FyndError::Api { code: ErrorCode::ServiceUnavailable, message: String::new() }
184            .is_retryable());
185    }
186
187    #[test]
188    fn is_retryable_false_for_non_retryable_errors() {
189        assert!(
190            !FyndError::Api { code: ErrorCode::BadRequest, message: String::new() }.is_retryable()
191        );
192        assert!(!FyndError::Api { code: ErrorCode::NoRouteFound, message: String::new() }
193            .is_retryable());
194        assert!(!FyndError::Protocol("bad data".into()).is_retryable());
195        assert!(!FyndError::Config("missing sender".into()).is_retryable());
196    }
197}