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}