Skip to main content

fyuz/
error.rs

1//! Error type for every call in this crate.
2
3use std::time::Duration;
4
5use serde::Deserialize;
6
7/// Convenience alias: every fallible call in this crate returns this.
8pub type Result<T, E = Error> = std::result::Result<T, E>;
9
10/// Anything that can go wrong talking to the Fyuz API.
11///
12/// The two variants worth matching on explicitly are [`Error::RateLimited`] —
13/// raised only after the client has exhausted its automatic retries — and
14/// [`Error::Api`], which carries the HTTP status and the server's own message
15/// parsed out of the `{"error": "..."}` envelope.
16///
17/// ```
18/// use fyuz::Error;
19///
20/// fn describe(err: &Error) -> String {
21///     match err {
22///         Error::RateLimited { retry_after, .. } => {
23///             format!("slow down, retry after {:?}", retry_after)
24///         }
25///         Error::Api { status: 404, .. } => "no such token".to_string(),
26///         other => other.to_string(),
27///     }
28/// }
29/// ```
30#[derive(Debug, thiserror::Error)]
31#[non_exhaustive]
32pub enum Error {
33    /// The API answered with a non-2xx status other than 429.
34    ///
35    /// `message` is the server's `{"error": "..."}` text when the body carried
36    /// one, otherwise a trimmed excerpt of the raw body.
37    #[error("fyuz api error (HTTP {status}): {message}")]
38    Api {
39        /// HTTP status code.
40        status: u16,
41        /// Server-supplied message.
42        message: String,
43    },
44
45    /// The API rate limit (120 requests/minute per IP) was hit and the
46    /// configured number of retries did not clear it.
47    #[error("fyuz api rate limited (HTTP 429): {message}")]
48    RateLimited {
49        /// Server-supplied message, normally `"Too many requests"`.
50        message: String,
51        /// Value of the `Retry-After` header, when the server sent one.
52        retry_after: Option<Duration>,
53    },
54
55    /// The request never produced an HTTP response: DNS failure, connection
56    /// refused, TLS failure or the configured timeout elapsing.
57    #[error("http transport error: {0}")]
58    Transport(#[from] reqwest::Error),
59
60    /// A 2xx response arrived but did not deserialise into the expected type.
61    #[error("could not decode response body: {message} (body began: {body})")]
62    Decode {
63        /// The serde error.
64        message: String,
65        /// First 256 bytes of the body, lossily decoded, to make the mismatch
66        /// obvious in a log line.
67        body: String,
68    },
69
70    /// [`FyuzClientBuilder::base_url`](crate::FyuzClientBuilder::base_url) was
71    /// given something that is not a usable HTTP base URL.
72    #[error("invalid base url {url:?}: {message}")]
73    InvalidBaseUrl {
74        /// The rejected input.
75        url: String,
76        /// Why it was rejected.
77        message: String,
78    },
79
80    /// An argument failed validation before any request was sent.
81    ///
82    /// Raised by the trading layer for a malformed address, an amount that is
83    /// not a whole number of wei, or a build called without slippage protection.
84    #[error("{message}")]
85    InvalidArgument {
86        /// What was wrong, and usually what to do instead.
87        message: String,
88    },
89
90    /// The contract rejected a call.
91    ///
92    /// Raised from a simulated `eth_call`, so it costs nothing — this is the
93    /// failure you want, rather than paying gas to discover the same thing
94    /// on-chain. [`Error::is_graduated`] picks out the case where the token has
95    /// left the bonding curve.
96    #[error("{message}")]
97    ContractRevert {
98        /// Classification from `shared/test-vectors/trade-errors.json`.
99        kind: crate::revert::RevertKind,
100        /// Human-readable explanation, including what to do about it.
101        message: String,
102        /// Solidity signature, e.g. `SlippageExceeded()`, when recognised.
103        error_name: Option<String>,
104        /// The 4-byte selector, when the revert carried one.
105        selector: Option<String>,
106        /// Raw revert data, kept so an unrecognised revert is still diagnosable.
107        data: String,
108        /// Whether re-quoting and retrying could plausibly succeed.
109        retryable: bool,
110        /// The token, when this is a graduation refusal.
111        token_address: Option<String>,
112    },
113
114    /// A JSON-RPC node returned an error, or something that was not a JSON-RPC
115    /// response.
116    #[error("RPC {method} failed: {message}")]
117    Rpc {
118        /// The method that failed, e.g. `eth_call`.
119        method: String,
120        /// The node's message.
121        message: String,
122        /// JSON-RPC error code, when the node sent a well-formed error object.
123        code: Option<i64>,
124        /// The node's `error.data`, kept structurally rather than folded into
125        /// `message`.
126        ///
127        /// This is where the revert payload lives on most providers, and it is
128        /// the *only* place it can be read unambiguously: a node is free to put
129        /// an address in its prose, and an address is longer than a 4-byte
130        /// selector, so recovering the selector by scanning text is a guess.
131        data: Option<serde_json::Value>,
132    },
133
134    /// A JSON-RPC request never produced a response.
135    ///
136    /// Distinct from [`Error::Transport`] so the failing method is visible: a
137    /// caller reading `http transport error: …` cannot tell an unreachable node
138    /// from an unreachable API. The underlying `reqwest::Error` is kept, so
139    /// [`Error::is_timeout`] still answers correctly.
140    #[error("RPC {method} could not reach the RPC endpoint: {source}")]
141    RpcTransport {
142        /// The method that failed, e.g. `eth_call`.
143        method: String,
144        /// The transport failure underneath.
145        #[source]
146        source: reqwest::Error,
147    },
148}
149
150impl Error {
151    /// HTTP status, when the failure came from an HTTP response.
152    pub fn status(&self) -> Option<u16> {
153        match self {
154            Error::Api { status, .. } => Some(*status),
155            Error::RateLimited { .. } => Some(429),
156            Error::Transport(e) | Error::RpcTransport { source: e, .. } => {
157                e.status().map(|s| s.as_u16())
158            }
159            _ => None,
160        }
161    }
162
163    /// `true` for [`Error::RateLimited`].
164    pub fn is_rate_limited(&self) -> bool {
165        matches!(self, Error::RateLimited { .. })
166    }
167
168    /// `true` when the API answered `404` — an unknown token, profile or round.
169    pub fn is_not_found(&self) -> bool {
170        self.status() == Some(404)
171    }
172
173    /// `true` when a trade was refused because the token left the bonding curve.
174    ///
175    /// The curve is closed once a token graduates, and every swap entrypoint
176    /// reverts. Trade its DEX pair instead.
177    pub fn is_graduated(&self) -> bool {
178        matches!(
179            self,
180            Error::ContractRevert {
181                kind: crate::revert::RevertKind::Graduated,
182                ..
183            }
184        )
185    }
186
187    /// `true` when the contract rejected the call and retrying could plausibly
188    /// help — a slippage miss or an expired deadline, not a closed curve.
189    pub fn is_retryable_revert(&self) -> bool {
190        matches!(
191            self,
192            Error::ContractRevert {
193                retryable: true,
194                ..
195            }
196        )
197    }
198
199    /// `true` when the request timed out rather than being answered.
200    pub fn is_timeout(&self) -> bool {
201        matches!(
202            self,
203            Error::Transport(e) | Error::RpcTransport { source: e, .. } if e.is_timeout()
204        )
205    }
206
207    /// The server's `Retry-After`, when it sent one with a 429.
208    pub fn retry_after(&self) -> Option<Duration> {
209        match self {
210            Error::RateLimited { retry_after, .. } => *retry_after,
211            _ => None,
212        }
213    }
214}
215
216/// The `{"error": "..."}` envelope every non-2xx response on this API carries.
217#[derive(Debug, Deserialize)]
218pub(crate) struct ErrorEnvelope {
219    pub(crate) error: Option<String>,
220}
221
222/// Best-effort extraction of a human message from an error body.
223pub(crate) fn message_from_body(body: &[u8], status: reqwest::StatusCode) -> String {
224    if let Ok(envelope) = serde_json::from_slice::<ErrorEnvelope>(body) {
225        if let Some(message) = envelope.error {
226            if !message.is_empty() {
227                return message;
228            }
229        }
230    }
231
232    let raw = String::from_utf8_lossy(body);
233    let trimmed = raw.trim();
234    if trimmed.is_empty() {
235        status
236            .canonical_reason()
237            .unwrap_or("unknown error")
238            .to_string()
239    } else {
240        truncate(trimmed, 256)
241    }
242}
243
244/// Clip a body excerpt to `max` characters so error messages stay loggable.
245pub(crate) fn truncate(text: &str, max: usize) -> String {
246    if text.chars().count() <= max {
247        return text.to_string();
248    }
249    let mut out: String = text.chars().take(max).collect();
250    out.push('…');
251    out
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use reqwest::StatusCode;
258
259    #[test]
260    fn parses_the_error_envelope() {
261        let body = br#"{"error":"Too many requests"}"#;
262        assert_eq!(
263            message_from_body(body, StatusCode::TOO_MANY_REQUESTS),
264            "Too many requests"
265        );
266    }
267
268    #[test]
269    fn falls_back_to_the_raw_body() {
270        let body = b"upstream exploded";
271        assert_eq!(
272            message_from_body(body, StatusCode::BAD_GATEWAY),
273            "upstream exploded"
274        );
275    }
276
277    #[test]
278    fn falls_back_to_the_status_reason_for_empty_bodies() {
279        assert_eq!(message_from_body(b"", StatusCode::NOT_FOUND), "Not Found");
280    }
281
282    #[test]
283    fn classifies_statuses() {
284        let err = Error::Api {
285            status: 404,
286            message: "nope".into(),
287        };
288        assert!(err.is_not_found());
289        assert!(!err.is_rate_limited());
290
291        let limited = Error::RateLimited {
292            message: "Too many requests".into(),
293            retry_after: Some(Duration::from_secs(2)),
294        };
295        assert!(limited.is_rate_limited());
296        assert_eq!(limited.status(), Some(429));
297        assert_eq!(limited.retry_after(), Some(Duration::from_secs(2)));
298    }
299}