Skip to main content

alloy_transport/
error.rs

1use alloy_json_rpc::{ErrorPayload, Id, RpcError, RpcResult};
2use serde::Deserialize;
3use serde_json::value::RawValue;
4use std::{error::Error as StdError, fmt::Debug, time::Duration};
5use thiserror::Error;
6
7/// A transport error is an [`RpcError`] containing a [`TransportErrorKind`].
8pub type TransportError<ErrResp = Box<RawValue>> = RpcError<TransportErrorKind, ErrResp>;
9
10/// A transport result is a [`Result`] containing a [`TransportError`].
11pub type TransportResult<T, ErrResp = Box<RawValue>> = RpcResult<T, TransportErrorKind, ErrResp>;
12
13/// Transport error.
14///
15/// All transport errors are wrapped in this enum.
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum TransportErrorKind {
19    /// Missing batch response.
20    ///
21    /// This error is returned when a batch request is sent and the response
22    /// does not contain a response for a request. For convenience the ID is
23    /// specified.
24    #[error("missing response for request with ID {0}")]
25    MissingBatchResponse(Id),
26
27    /// Backend connection task has stopped.
28    #[error("backend connection task has stopped")]
29    BackendGone,
30
31    /// Pubsub service is not available for the current provider.
32    #[error("subscriptions are not available on this provider")]
33    PubsubUnavailable,
34
35    /// HTTP Error with code and body
36    #[error("{0}")]
37    HttpError(#[from] HttpError),
38
39    /// HTTP error with a server-provided retry delay.
40    ///
41    /// Error statuses (4xx/5xx) are always considered retryable since the server explicitly
42    /// requested a retry. Redirects are not: `Retry-After` on a 3xx delays the redirected
43    /// request, not a replay of the original one.
44    #[error("{error}")]
45    HttpErrorWithRetryAfter {
46        /// The HTTP error.
47        #[source]
48        error: HttpError,
49        /// The delay requested by the server.
50        retry_after: Duration,
51    },
52
53    /// Custom error.
54    #[error("{0}")]
55    Custom(#[source] Box<dyn StdError + Send + Sync + 'static>),
56
57    /// Deterministic, terminal failure that retry loops should not retry.
58    ///
59    /// Use this when the operation is guaranteed to fail again on retry —
60    /// for example a malformed request, a permission error, or any other
61    /// protocol-level failure that is not a transient socket / IO problem.
62    #[error("{0}")]
63    NonRetryable(#[source] Box<dyn StdError + Send + Sync + 'static>),
64}
65
66impl TransportErrorKind {
67    /// Returns `true` if the error is potentially recoverable.
68    /// This is a naive heuristic and should be used with caution.
69    pub const fn recoverable(&self) -> bool {
70        matches!(self, Self::MissingBatchResponse(_))
71    }
72
73    /// Instantiate a new `TransportError` from a custom error.
74    pub fn custom_str(err: &str) -> TransportError {
75        RpcError::Transport(Self::Custom(err.into()))
76    }
77
78    /// Instantiate a new `TransportError` from a custom error.
79    pub fn custom(err: impl StdError + Send + Sync + 'static) -> TransportError {
80        RpcError::Transport(Self::Custom(Box::new(err)))
81    }
82
83    /// Instantiate a new non-retryable `TransportError` from a string.
84    pub fn non_retryable_str(err: &str) -> TransportError {
85        RpcError::Transport(Self::NonRetryable(err.into()))
86    }
87
88    /// Instantiate a new non-retryable `TransportError` from a custom error.
89    pub fn non_retryable(err: impl StdError + Send + Sync + 'static) -> TransportError {
90        RpcError::Transport(Self::NonRetryable(Box::new(err)))
91    }
92
93    /// Returns true if this is [`TransportErrorKind::NonRetryable`].
94    pub const fn is_non_retryable(&self) -> bool {
95        matches!(self, Self::NonRetryable(_))
96    }
97
98    /// Instantiate a new `TransportError` from a missing ID.
99    pub const fn missing_batch_response(id: Id) -> TransportError {
100        RpcError::Transport(Self::MissingBatchResponse(id))
101    }
102
103    /// Instantiate a new `TransportError::BackendGone`.
104    pub const fn backend_gone() -> TransportError {
105        RpcError::Transport(Self::BackendGone)
106    }
107
108    /// Instantiate a new `TransportError::PubsubUnavailable`.
109    pub const fn pubsub_unavailable() -> TransportError {
110        RpcError::Transport(Self::PubsubUnavailable)
111    }
112
113    /// Instantiate a new `TransportError::HttpError`.
114    pub const fn http_error(status: u16, body: String) -> TransportError {
115        RpcError::Transport(Self::HttpError(HttpError { status, body }))
116    }
117
118    /// Instantiate a new HTTP error that optionally carries the retry delay requested by the
119    /// server via a `Retry-After` header.
120    pub const fn http_error_with_retry_after(
121        status: u16,
122        body: String,
123        retry_after: Option<Duration>,
124    ) -> TransportError {
125        match retry_after {
126            Some(retry_after) => RpcError::Transport(Self::HttpErrorWithRetryAfter {
127                error: HttpError { status, body },
128                retry_after,
129            }),
130            None => Self::http_error(status, body),
131        }
132    }
133
134    /// Returns true if this is [`TransportErrorKind::PubsubUnavailable`].
135    pub const fn is_pubsub_unavailable(&self) -> bool {
136        matches!(self, Self::PubsubUnavailable)
137    }
138
139    /// Returns true if this is [`TransportErrorKind::BackendGone`].
140    pub const fn is_backend_gone(&self) -> bool {
141        matches!(self, Self::BackendGone)
142    }
143
144    /// Returns true if this is an HTTP error.
145    pub const fn is_http_error(&self) -> bool {
146        matches!(self, Self::HttpError(_) | Self::HttpErrorWithRetryAfter { .. })
147    }
148
149    /// Returns the [`HttpError`] if this is an HTTP error.
150    pub const fn as_http_error(&self) -> Option<&HttpError> {
151        match self {
152            Self::HttpError(err) => Some(err),
153            Self::HttpErrorWithRetryAfter { error, .. } => Some(error),
154            _ => None,
155        }
156    }
157
158    /// Returns the server-provided retry delay, if present.
159    pub const fn retry_after(&self) -> Option<Duration> {
160        match self {
161            Self::HttpErrorWithRetryAfter { retry_after, .. } => Some(*retry_after),
162            _ => None,
163        }
164    }
165
166    /// Returns the custom error if this is [`TransportErrorKind::Custom`].
167    pub const fn as_custom(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
168        match self {
169            Self::Custom(err) => Some(&**err),
170            _ => None,
171        }
172    }
173
174    /// Analyzes the [TransportErrorKind] and decides if the request should be retried based on the
175    /// variant.
176    pub fn is_retry_err(&self) -> bool {
177        match self {
178            // Missing batch response errors can be retried.
179            Self::MissingBatchResponse(_) => true,
180            Self::HttpError(http_err) => {
181                http_err.is_rate_limit_err() || http_err.is_temporarily_unavailable()
182            }
183            // The server explicitly requested a retry with a delay. Redirects are excluded
184            // because their `Retry-After` delays the redirected request, not a replay.
185            Self::HttpErrorWithRetryAfter { error, .. } => error.status >= 400,
186            Self::Custom(err) => {
187                let msg = err.to_string();
188                msg.contains("429 Too Many Requests")
189            }
190            _ => false,
191        }
192    }
193}
194
195/// Type for holding HTTP errors such as 429 rate limit error.
196#[derive(Debug, thiserror::Error)]
197#[error(
198    "HTTP error {status} with {}",
199    if body.is_empty() { "empty body".to_string() } else { format!("body: {body}") }
200)]
201pub struct HttpError {
202    /// The HTTP status code.
203    pub status: u16,
204    /// The HTTP response body.
205    pub body: String,
206}
207
208impl HttpError {
209    /// Checks the `status` to determine whether the request should be retried.
210    pub const fn is_rate_limit_err(&self) -> bool {
211        self.status == 429
212    }
213
214    /// Checks the `status` to determine whether the service was temporarily unavailable and should
215    /// be retried.
216    pub const fn is_temporarily_unavailable(&self) -> bool {
217        self.status == 503
218    }
219}
220
221/// Extension trait to implement methods for [`RpcError<TransportErrorKind, E>`].
222pub(crate) trait RpcErrorExt {
223    /// Analyzes whether to retry the request depending on the error.
224    fn is_retryable(&self) -> bool;
225
226    /// Fetches the backoff hint from the error message if present
227    fn backoff_hint(&self) -> Option<std::time::Duration>;
228}
229
230impl RpcErrorExt for RpcError<TransportErrorKind> {
231    fn is_retryable(&self) -> bool {
232        match self {
233            // There was a transport-level error. This is either a non-retryable error,
234            // or a server error that should be retried.
235            Self::Transport(err) => err.is_retry_err(),
236            // The transport could not serialize the error itself. The request was malformed from
237            // the start.
238            Self::SerError(_) => false,
239            Self::DeserError { text, .. } => {
240                if let Ok(resp) = serde_json::from_str::<ErrorPayload>(text) {
241                    return resp.is_retry_err();
242                }
243
244                // some providers send invalid JSON RPC in the error case (no `id:u64`), but the
245                // text should be a `JsonRpcError`
246                #[derive(Deserialize)]
247                struct Resp {
248                    error: ErrorPayload,
249                }
250
251                if let Ok(resp) = serde_json::from_str::<Resp>(text) {
252                    return resp.error.is_retry_err();
253                }
254
255                false
256            }
257            Self::ErrorResp(err) => err.is_retry_err(),
258            Self::NullResp => true,
259            _ => false,
260        }
261    }
262
263    fn backoff_hint(&self) -> Option<std::time::Duration> {
264        // Server-provided hints are clamped so that a misbehaving server cannot stall retries
265        // with an absurd delay.
266        raw_backoff_hint(self).map(|hint| hint.min(MAX_BACKOFF_HINT))
267    }
268}
269
270/// Maximum server-provided backoff honored, whether from a `Retry-After` header or an error
271/// payload.
272const MAX_BACKOFF_HINT: Duration = Duration::from_secs(5 * 60);
273
274/// Extracts the backoff hint from the error, unbounded.
275fn raw_backoff_hint(err: &RpcError<TransportErrorKind>) -> Option<Duration> {
276    if let RpcError::Transport(TransportErrorKind::HttpErrorWithRetryAfter {
277        retry_after, ..
278    }) = err
279    {
280        return Some(*retry_after);
281    }
282
283    if let RpcError::ErrorResp(resp) = err {
284        // try to extract backoff from the error data (infura-style)
285        let data = resp.try_data_as::<serde_json::Value>();
286        if let Some(Ok(data)) = data {
287            // if daily rate limit exceeded, infura returns the requested backoff in the error
288            // response
289            let backoff_seconds = &data["rate"]["backoff_seconds"];
290            // infura rate limit error
291            if let Some(seconds) = backoff_seconds.as_u64() {
292                return Some(Duration::from_secs(seconds));
293            }
294            if let Some(seconds) = backoff_seconds.as_f64() {
295                return Some(Duration::from_secs((seconds as u64).saturating_add(1)));
296            }
297        }
298
299        // try to extract backoff from the error message, e.g. "try again in 4ms"
300        if let Some(duration) = parse_retry_after(&resp.message) {
301            return Some(duration);
302        }
303    }
304    None
305}
306
307/// Parses a duration from messages like "try again in 4ms", "try again in 1s".
308fn parse_retry_after(message: &str) -> Option<std::time::Duration> {
309    let after = message.split_once("try again in ")?.1.trim_start();
310
311    let digits_len = after.as_bytes().iter().take_while(|b| b.is_ascii_digit()).count();
312    let (digits, rest) = after.split_at(digits_len);
313    let value: u64 = digits.parse().ok()?;
314
315    let unit = rest.trim().trim_end_matches(|c: char| c.is_ascii_punctuation());
316    match unit {
317        "ms" => Some(std::time::Duration::from_millis(value)),
318        "s" => Some(std::time::Duration::from_secs(value)),
319        _ => None,
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_retry_error() {
329        let err = "{\"code\":-32007,\"message\":\"100/second request limit reached - reduce calls per second or upgrade your account at quicknode.com\"}";
330        let err = serde_json::from_str::<ErrorPayload>(err).unwrap();
331        assert!(TransportError::ErrorResp(err).is_retryable());
332    }
333
334    #[test]
335    fn test_retry_error_rate_limited() {
336        let err = r#"{"code":-32005,"message":"rate limited, try again in 4ms","data":null}"#;
337        let err = serde_json::from_str::<ErrorPayload>(err).unwrap();
338        let err = TransportError::ErrorResp(err);
339        assert!(err.is_retryable());
340        assert_eq!(err.backoff_hint(), Some(std::time::Duration::from_millis(4)));
341    }
342
343    #[test]
344    fn parse_retry_after_millis() {
345        assert_eq!(
346            parse_retry_after("try again in 4ms"),
347            Some(std::time::Duration::from_millis(4))
348        );
349        assert_eq!(
350            parse_retry_after("rate limited, try again in 100ms"),
351            Some(std::time::Duration::from_millis(100))
352        );
353    }
354
355    #[test]
356    fn parse_retry_after_seconds() {
357        assert_eq!(parse_retry_after("try again in 2s"), Some(std::time::Duration::from_secs(2)));
358    }
359
360    #[test]
361    fn parse_retry_after_none() {
362        assert_eq!(parse_retry_after("some other error"), None);
363        assert_eq!(parse_retry_after("try again in"), None);
364        assert_eq!(parse_retry_after("try again in ms"), None);
365        assert_eq!(parse_retry_after("try again in 4us"), None);
366    }
367
368    #[test]
369    fn test_retry_error_429() {
370        let err = r#"{"code":429,"event":-33200,"message":"Too Many Requests","details":"You have surpassed your allowed throughput limit. Reduce the amount of requests per second or upgrade for more capacity."}"#;
371        let err = serde_json::from_str::<ErrorPayload>(err).unwrap();
372        assert!(TransportError::ErrorResp(err).is_retryable());
373    }
374
375    #[test]
376    fn http_retry_after_retryable_for_error_statuses() {
377        let err = TransportErrorKind::http_error_with_retry_after(
378            500,
379            String::new(),
380            Some(Duration::from_secs(1)),
381        );
382        assert!(err.is_retryable());
383        assert_eq!(err.backoff_hint(), Some(Duration::from_secs(1)));
384
385        // without the header a plain 500 stays non-retryable
386        assert!(!TransportErrorKind::http_error(500, String::new()).is_retryable());
387
388        // redirects are not replayed even with a Retry-After
389        let err = TransportErrorKind::http_error_with_retry_after(
390            301,
391            String::new(),
392            Some(Duration::from_secs(1)),
393        );
394        assert!(!err.is_retryable());
395    }
396
397    #[test]
398    fn backoff_hint_clamped() {
399        let err = TransportErrorKind::http_error_with_retry_after(
400            429,
401            String::new(),
402            Some(Duration::from_secs(u64::MAX)),
403        );
404        assert_eq!(err.backoff_hint(), Some(MAX_BACKOFF_HINT));
405    }
406
407    #[test]
408    fn http_retry_after_preserves_http_error() {
409        let err = TransportErrorKind::http_error_with_retry_after(
410            429,
411            "Too Many Requests".to_owned(),
412            Some(Duration::from_secs(52)),
413        );
414
415        assert!(err.is_retryable());
416        assert_eq!(err.backoff_hint(), Some(Duration::from_secs(52)));
417
418        let TransportError::Transport(kind) = err else { panic!("expected transport error") };
419        assert!(kind.is_http_error());
420        assert_eq!(kind.retry_after(), Some(Duration::from_secs(52)));
421        assert_eq!(kind.as_http_error().unwrap().status, 429);
422    }
423}