Skip to main content

robust_provider/robust_provider/
provider.rs

1//! Core [`RobustProvider`] implementation with retry and failover logic.
2
3use std::{borrow::Cow, fmt::Debug, future::Future, time::Duration};
4
5use backon::{ExponentialBuilder, Retryable};
6use serde_json::value::RawValue;
7use tokio::time::timeout;
8
9use super::errors::{FailoverError, is_retryable_error};
10use alloy::{
11    consensus::{BlockHeader, TrieAccount},
12    eips::{BlockId, BlockNumberOrTag, eip1559::Eip1559Estimation},
13    network::{BlockResponse, Ethereum, Network},
14    primitives::{
15        Address, B256, BlockHash, BlockNumber, Bytes, StorageKey, StorageValue, TxHash, U256,
16    },
17    providers::{
18        PendingTransactionBuilder, Provider, RootProvider,
19        utils::{
20            EIP1559_FEE_ESTIMATION_PAST_BLOCKS, EIP1559_FEE_ESTIMATION_REWARD_PERCENTILE,
21            Eip1559Estimator,
22        },
23    },
24    rpc::{
25        client::PollerBuilder,
26        json_rpc::{RpcRecv, RpcSend},
27        types::{
28            AccessListResult, AccountInfo, Bundle, EIP1186AccountProofResponse, EthCallResponse,
29            FeeHistory, FillTransaction, Filter, Log, SyncStatus,
30            erc4337::TransactionConditional,
31            simulate::{SimulatePayload, SimulatedBlock},
32        },
33    },
34    transports::{RpcError, TransportErrorKind},
35};
36
37use crate::{
38    Error, block_not_found_doc,
39    robust_provider::{RobustSubscription, subscription::SubscriptionBackend},
40};
41
42/// Provider wrapper with built-in retry and timeout mechanisms.
43///
44/// This wrapper around Alloy providers automatically handles retries,
45/// timeouts, and error logging for RPC calls.
46#[derive(Clone, Debug)]
47pub struct RobustProvider<N: Network = Ethereum> {
48    pub(crate) primary_provider: RootProvider<N>,
49    pub(crate) fallback_providers: Vec<RootProvider<N>>,
50    pub(crate) call_timeout: Duration,
51    pub(crate) subscription_timeout: Duration,
52    pub(crate) max_retries: usize,
53    pub(crate) min_delay: Duration,
54    pub(crate) reconnect_interval: Duration,
55    pub(crate) subscription_buffer_capacity: usize,
56    /// Polling interval for HTTP-based subscriptions.
57    #[cfg(feature = "http-subscription")]
58    pub(crate) poll_interval: Duration,
59    /// Whether HTTP providers can participate in subscriptions via polling.
60    #[cfg(feature = "http-subscription")]
61    pub(crate) allow_http_subscriptions: bool,
62}
63
64impl<N: Network> RobustProvider<N> {
65    #[must_use]
66    pub fn primary(&self) -> &RootProvider<N> {
67        &self.primary_provider
68    }
69
70    #[must_use]
71    pub fn fallback_providers(&self) -> &[RootProvider<N>] {
72        &self.fallback_providers
73    }
74
75    #[must_use]
76    pub fn call_timeout(&self) -> Duration {
77        self.call_timeout
78    }
79
80    #[must_use]
81    pub fn max_retries(&self) -> usize {
82        self.max_retries
83    }
84
85    #[must_use]
86    pub fn min_delay(&self) -> Duration {
87        self.min_delay
88    }
89
90    robust_rpc!(fn get_accounts() -> Vec<Address>);
91    robust_rpc!(fn get_blob_base_fee() -> u128);
92
93    robust_rpc!(
94        doc_args = [(tx, "The transaction request to simulate.")]
95        @clone [tx]
96        fn call(tx: N::TransactionRequest) -> Bytes
97    );
98
99    robust_rpc!(
100        doc_args = [(bundles, "A slice of transaction bundles to execute.")]
101        fn call_many(bundles: &[Bundle]) -> Vec<Vec<EthCallResponse>>
102    );
103
104    robust_rpc!(fn get_chain_id() -> u64);
105
106    robust_rpc!(fn get_net_version() -> u64);
107
108    robust_rpc!(
109        doc_args = [(data, "The data to hash.")]
110        fn get_sha3(data: &[u8]) -> B256
111    );
112
113    robust_rpc!(
114        doc_args = [(request, "The transaction request to create an access list for.")]
115        fn create_access_list(request: &N::TransactionRequest) -> AccessListResult
116    );
117
118    robust_rpc!(
119        doc_args = [(tx, "The transaction request to estimate gas for.")]
120        @clone [tx]
121        fn estimate_gas(tx: N::TransactionRequest) -> u64
122    );
123
124    robust_rpc!(
125        fn estimate_eip1559_fees() -> Eip1559Estimation
126    );
127
128    robust_rpc!(
129        doc_args = [
130            (block_count, "The number of blocks to include in the fee history."),
131            (last_block, "The last block to include in the fee history."),
132            (reward_percentiles, "A list of percentiles to compute reward values for.")
133        ]
134        fn get_fee_history(block_count: u64, last_block: BlockNumberOrTag, reward_percentiles: &[f64]) -> FeeHistory
135    );
136
137    robust_rpc!(fn get_gas_price() -> u128);
138    robust_rpc!(fn get_max_priority_fee_per_gas() -> u128);
139
140    robust_rpc!(
141        doc_args = [(address, "The address for which to get the account info.")]
142        fn get_account_info(address: Address) -> AccountInfo
143    );
144
145    robust_rpc!(
146        doc_args = [(address, "The address to get the account for.")]
147        fn get_account(address: Address) -> TrieAccount
148    );
149
150    robust_rpc!(
151        doc_args = [(address, "The address to get the balance for.")]
152        fn get_balance(address: Address) -> U256
153    );
154
155    robust_rpc!(
156        doc_include_error = [block_not_found_doc!()]
157        doc_args = [(number, "The block number or tag.")]
158        fn get_block_by_number(number: BlockNumberOrTag) -> N::BlockResponse; or BlockNotFound
159    );
160
161    robust_rpc!(
162        doc_include_error = [block_not_found_doc!()]
163        doc_args = [(id, "The block identifier.")]
164        fn get_block(id: BlockId) -> N::BlockResponse; or BlockNotFound
165    );
166
167    robust_rpc!(fn get_block_number() -> BlockNumber);
168
169    robust_rpc!(
170        doc_include_error = [block_not_found_doc!()]
171        doc_args = [(block_id, "The block identifier to fetch the block number for.")]
172        fn get_block_number_by_id(block_id: BlockId) -> BlockNumber; or BlockNotFound
173    );
174
175    robust_rpc!(
176        doc_include_error = [block_not_found_doc!()]
177        doc_args = [(hash, "The block hash.")]
178        fn get_block_by_hash(hash: BlockHash) -> N::BlockResponse; or BlockNotFound
179    );
180
181    robust_rpc!(
182        doc_include_error = [block_not_found_doc!()]
183        doc_args = [(block, "The block identifier (hash, number, or tag).")]
184        fn get_block_receipts(block: BlockId) -> Vec<N::ReceiptResponse>; or BlockNotFound
185    );
186
187    robust_rpc!(
188        doc_include_error = [block_not_found_doc!()]
189        doc_args = [(hash, "The block hash.")]
190        fn get_block_transaction_count_by_hash(hash: BlockHash) -> u64; or BlockNotFound
191    );
192
193    robust_rpc!(
194        doc_include_error = [block_not_found_doc!()]
195        doc_args = [(block_number, "The block number or tag.")]
196        fn get_block_transaction_count_by_number(block_number: BlockNumberOrTag) -> u64; or BlockNotFound
197    );
198
199    robust_rpc!(
200        doc_args = [(filter, "The log filter.")]
201        fn get_logs(filter: &Filter) -> Vec<Log>
202    );
203
204    robust_rpc!(
205        doc_args = [(address, "The address to get the code for.")]
206        fn get_code_at(address: Address) -> Bytes
207    );
208
209    robust_rpc!(
210        doc_args = [(filter_id, "The filter ID to fetch logs for.")]
211        fn get_filter_logs(filter_id: U256) -> Vec<Log>
212    );
213
214    robust_rpc!(
215        doc_args = [(filter_id, "The filter ID to get changes for.")]
216        fn get_filter_changes<R: RpcRecv>(filter_id: U256) -> Vec<R>
217    );
218
219    robust_rpc!(
220        doc_args = [(filter, "The filter to create.")]
221        fn new_filter(filter: &Filter) -> U256
222    );
223
224    robust_rpc!(fn new_block_filter() -> U256);
225
226    robust_rpc!(
227        doc_args = [(full, "Whether to include full transaction objects.")]
228        fn new_pending_transactions_filter(full: bool) -> U256
229    );
230
231    robust_rpc!(
232        doc_args = [
233            (tx, "The transaction request to sign.")
234        ]
235        @clone [tx]
236        fn sign_transaction(tx: N::TransactionRequest) -> Bytes
237    );
238
239    robust_rpc!(
240        doc_args = [
241            (tx, "The transaction request to fill.")
242        ]
243        @clone [tx]
244        fn fill_transaction(tx: N::TransactionRequest) -> FillTransaction<N::TxEnvelope>
245        where N::TxEnvelope: RpcRecv
246    );
247
248    robust_rpc!(
249        doc_args = [
250            (address, "The address of the account."),
251            (keys, "A vector of storage keys to include in the proof.")
252        ]
253        @clone [keys]
254        fn get_proof(address: Address, keys: Vec<StorageKey>) -> EIP1186AccountProofResponse
255    );
256
257    robust_rpc!(
258        doc_args = [
259            (address, "The address of the storage."),
260            (key, "The position in the storage.")
261        ]
262        fn get_storage_at(address: Address, key: U256) -> StorageValue
263    );
264
265    robust_rpc!(
266        doc_args = [
267            (block_hash, "The hash of the block."),
268            (index, "The transaction index position.")
269        ]
270        fn get_transaction_by_block_hash_and_index(block_hash: B256, index: usize) -> Option<N::TransactionResponse>
271    );
272
273    robust_rpc!(
274        doc_args = [
275            (block_number, "The block number or tag."),
276            (index, "The transaction index position.")
277        ]
278        fn get_transaction_by_block_number_and_index(block_number: BlockNumberOrTag, index: usize) -> Option<N::TransactionResponse>
279    );
280
281    robust_rpc!(
282        doc_args = [(hash, "The transaction hash.")]
283        fn get_transaction_by_hash(hash: TxHash) -> Option<N::TransactionResponse>
284    );
285
286    robust_rpc!(
287        doc_args = [(hash, "The transaction hash.")]
288        fn get_raw_transaction_by_hash(hash: TxHash) -> Option<Bytes>
289    );
290
291    robust_rpc!(
292        doc_args = [(address, "The address to get the transaction count for.")]
293        fn get_transaction_count(address: Address) -> u64
294    );
295
296    robust_rpc!(
297        doc_args = [(hash, "The transaction hash.")]
298        fn get_transaction_receipt(hash: TxHash) -> Option<N::ReceiptResponse>
299    );
300
301    robust_rpc!(
302        doc_args = [(block, "The block identifier (hash or number).")]
303        fn get_uncle_count(block: BlockId) -> u64
304    );
305
306    robust_rpc!(
307        doc_args = [
308            (tag, "The block identifier (hash or number)."),
309            (idx, "The uncle index position.")
310        ]
311        fn get_uncle(tag: BlockId, idx: u64) -> Option<N::BlockResponse>
312    );
313
314    robust_rpc!(
315        doc_args = [(request, "The simulation request")]
316        fn simulate(request: &SimulatePayload) -> Vec<SimulatedBlock<N::BlockResponse>>
317    );
318
319    robust_rpc!(fn syncing() -> SyncStatus);
320
321    robust_rpc!(
322        doc_args = [(id, "The filter ID to uninstall.")]
323        fn uninstall_filter(id: U256) -> bool
324    );
325
326    robust_rpc!(
327        doc_args = [(encoded_tx, "The RLP-encoded signed transaction bytes")]
328        fn send_raw_transaction(encoded_tx: &[u8]) -> PendingTransactionBuilder<N>
329    );
330
331    robust_rpc!(
332        doc_args = [(encoded_tx, "The RLP-encoded signed transaction bytes")]
333        fn send_raw_transaction_sync(encoded_tx: &[u8]) -> N::ReceiptResponse
334    );
335
336    robust_rpc!(
337        doc_args = [
338            (encoded_tx, "The RLP-encoded signed transaction bytes"),
339            (conditional, "The transaction conditional to apply")
340        ]
341        @clone [conditional]
342        fn send_raw_transaction_conditional(encoded_tx: &[u8], conditional: TransactionConditional) -> PendingTransactionBuilder<N>
343    );
344
345    robust_rpc!(
346        doc_args = [(tx, "The transaction request to send")]
347        @clone [tx]
348        fn send_transaction(tx: N::TransactionRequest) -> PendingTransactionBuilder<N>
349    );
350
351    robust_rpc!(
352        doc_args = [
353            (tx, "The signed transaction envelope to send.")
354        ]
355        @clone [tx]
356        fn send_tx_envelope(tx: N::TxEnvelope) -> PendingTransactionBuilder<N>
357        where N::TxEnvelope: Clone
358    );
359
360    robust_rpc!(
361        doc_args = [(tx, "The transaction request to send synchronously")]
362        @clone [tx]
363        fn send_transaction_sync(tx: N::TransactionRequest) -> N::ReceiptResponse
364    );
365
366    robust_rpc!(
367        doc_args = [
368            (sender, "The sender address"),
369            (nonce, "The nonce of the transaction")
370        ]
371        fn get_transaction_by_sender_nonce(sender: Address, nonce: u64) -> Option<N::TransactionResponse>
372    );
373
374    robust_rpc!(
375        doc_args = [
376            (block_hash, "The hash of the block"),
377            (index, "The transaction index position")
378        ]
379        fn get_raw_transaction_by_block_hash_and_index(block_hash: B256, index: usize) -> Option<Bytes>
380    );
381
382    robust_rpc!(
383        doc_args = [
384            (block_number, "The block number or tag"),
385            (index, "The transaction index position")
386        ]
387        fn get_raw_transaction_by_block_number_and_index(block_number: BlockNumberOrTag, index: usize) -> Option<Bytes>
388    );
389
390    robust_rpc!(
391        doc_alias = "web3_client_version"
392        fn get_client_version() -> String
393    );
394
395    /// Estimates the [EIP-1559] `maxFeePerGas` and `maxPriorityFeePerGas` fields using a custom
396    /// estimator.
397    ///
398    /// # Implementation Note
399    ///
400    /// Unlike most methods in `RobustProvider`, this method **does not** wrap the underlying
401    /// provider's `estimate_eip1559_fees_with` call with retry/failover logic. Instead, it
402    /// implements the estimation logic directly (copied from alloy's implementation).
403    ///
404    /// Ref <https://github.com/alloy-rs/alloy/blob/9a90a57acde7f30787b675815334cf54c6be4ba1/crates/provider/src/provider/trait.rs#L283>
405    ///
406    /// **Reason:** This method accepts an **owned**, **non-cloneable** `Eip1559Estimator` value.
407    /// The current retry implementation (`try_operation_with_failover`) requires operations to
408    /// be retryable across multiple providers, which means closures must be able to run
409    /// multiple times. This only works with `Copy` or `Clone` types. Since `Eip1559Estimator`
410    /// consumes itself and cannot be cloned, we cannot use the standard retry wrapper.
411    ///
412    /// There is a pending issue on alloy <https://github.com/alloy-rs/alloy/issues/3669> which
413    /// would allow [`Eip1559Estimator`] to implement Clone. If this is merged we can remove this
414    /// custom impl.
415    ///
416    /// However, **the individual RPC calls** within this method (`get_fee_history` and
417    /// `get_block_by_number`) **do** benefit from full retry and failover support, as they use
418    /// the standard robust wrappers internally.
419    ///
420    /// # Parameters
421    ///
422    /// * `estimator` - The [`Eip1559Estimator`] to use for calculating fees.
423    ///
424    /// # Errors
425    ///
426    /// * [`Error::RpcError`] - if the underlying RPC calls fail after exhausting retries on all
427    ///   providers
428    /// * [`Error::Timeout`] - if the operation exceeds the configured timeout
429    /// * [`Error::RpcError`] with `UnsupportedFeature("eip1559")` - if the chain doesn't support
430    ///   EIP-1559 (i.e., the latest block has no `base_fee_per_gas`)
431    pub async fn estimate_eip1559_fees_with(
432        &self,
433        estimator: Eip1559Estimator,
434    ) -> Result<Eip1559Estimation, Error> {
435        let fee_history = self
436            .get_fee_history(
437                EIP1559_FEE_ESTIMATION_PAST_BLOCKS,
438                BlockNumberOrTag::Latest,
439                &[EIP1559_FEE_ESTIMATION_REWARD_PERCENTILE],
440            )
441            .await?;
442
443        // if the base fee of the Latest block is 0 then we need check if the latest block even has
444        // a base fee/supports EIP1559
445        let base_fee_per_gas = match fee_history.latest_block_base_fee() {
446            Some(base_fee) if base_fee != 0 => base_fee,
447            _ => {
448                // empty response, fetch basefee from latest block directly
449                self.get_block_by_number(BlockNumberOrTag::Latest)
450                    .await
451                    .map_err(|e| {
452                        // this is how alloy implements it - if block not found, return NullResp,
453                        // see: https://github.com/alloy-rs/alloy/blob/aef6655961fa4b75ccf3b8a45186c57b09e7954e/crates/provider/src/provider/trait.rs#L303
454                        if matches!(e, Error::BlockNotFound) {
455                            RpcError::NullResp.into()
456                        } else {
457                            e
458                        }
459                    })?
460                    .header()
461                    .as_ref()
462                    .base_fee_per_gas()
463                    .ok_or(RpcError::UnsupportedFeature("eip1559"))?
464                    .into()
465            }
466        };
467
468        Ok(estimator.estimate(base_fee_per_gas, &fee_history.reward.unwrap_or_default()))
469    }
470
471    robust_rpc!(
472            @clone [method, params]
473            fn raw_request<P, R>(method: Cow<'static, str>, params: P) -> R
474            where P: RpcSend, R: RpcRecv
475    );
476
477    robust_rpc!(
478         @clone [method]
479         fn raw_request_dyn(method: Cow<'static, str>, params: &RawValue) -> Box<RawValue>
480    );
481
482    /// Subscribe to new block headers with automatic failover and reconnection.
483    ///
484    /// Returns a [`RobustSubscription`] that automatically:
485    /// * Handles connection errors by switching to fallback providers
486    /// * Detects and recovers from lagged subscriptions
487    /// * Periodically attempts to reconnect to the primary provider
488    ///
489    /// When the `http-subscription` feature is enabled and
490    /// [`allow_http_subscriptions`](crate::RobustProviderBuilder::allow_http_subscriptions)
491    /// is set to `true`, HTTP providers can participate in subscriptions via polling.
492    ///
493    /// This is a wrapper function for [`Provider::subscribe_blocks`].
494    ///
495    /// # Errors
496    ///
497    /// * [`Error::RpcError`] - if no fallback providers succeeded; contains the last error returned
498    ///   by the last provider attempted on the last retry.
499    /// * [`Error::Timeout`] - if the overall operation timeout elapses (i.e. exceeds
500    ///   `call_timeout`).
501    pub async fn subscribe_blocks(&self) -> Result<RobustSubscription<N>, Error> {
502        let subscription: SubscriptionBackend<N> = self
503            .try_operation_with_failover(move |provider| async move {
504                // if HTTP subscriptions are enabled and the provider currently being tried is HTTP,
505                // we will attempt to connect using it.
506                // Otherwise try subscribing through a PubSub operation, and if the provider is HTTP
507                // just let it fail; the error will be non-retriable, so the algorithm will
508                // automatically switch to the next fallback provider (see
509                // `try_provider_with_timeout`).
510                #[cfg(feature = "http-subscription")]
511                {
512                    let not_pubsub = provider.client().pubsub_frontend().is_none();
513                    if not_pubsub && self.allow_http_subscriptions {
514                        return provider.watch_blocks().await.map(|builder| {
515                            builder
516                                .with_poll_interval(self.poll_interval)
517                                .with_channel_size(self.subscription_buffer_capacity)
518                                .into()
519                        });
520                    }
521                }
522                provider
523                    .subscribe_blocks()
524                    .channel_size(self.subscription_buffer_capacity)
525                    .await
526                    .map(Into::<SubscriptionBackend<N>>::into)
527            })
528            .await?;
529
530        Ok(RobustSubscription::new(subscription, self.clone()))
531    }
532
533    // TODO: set watch blocks params from the RP itself
534    robust_rpc!(fn watch_blocks() -> PollerBuilder<(U256,), Vec<BlockHash>>);
535
536    /// Execute `operation` with exponential backoff and a total timeout.
537    ///
538    /// Wraps the retry logic with [`tokio::time::timeout`] so
539    /// the entire operation (including time spent inside the RPC call) cannot exceed
540    /// `call_timeout`.
541    ///
542    /// If the timeout is exceeded and fallback providers are available, it will
543    /// attempt to use each fallback provider in sequence.
544    ///
545    /// # Errors
546    ///
547    /// * [`FailoverError::RpcError`] - if no fallback providers succeeded; contains the last error
548    ///   returned by the last provider attempted on the last retry.
549    /// * [`FailoverError::Timeout`] - if the overall operation timeout elapses (i.e. exceeds
550    ///   `call_timeout`).
551    pub async fn try_operation_with_failover<T: Debug, F, Fut>(
552        &self,
553        operation: F,
554    ) -> Result<T, FailoverError>
555    where
556        F: Fn(RootProvider<N>) -> Fut,
557        Fut: Future<Output = Result<T, RpcError<TransportErrorKind>>>,
558    {
559        let primary = self.primary();
560
561        match self.try_provider_with_timeout(primary, &operation).await {
562            Ok(value) => Ok(value),
563            Err(last_error) => self
564                .try_fallback_providers_from(&operation, last_error, 0)
565                .await
566                .map(|(value, _)| value),
567        }
568    }
569
570    /// Try fallback providers starting from a specific index.
571    pub(crate) async fn try_fallback_providers_from<T: Debug, F, Fut>(
572        &self,
573        operation: F,
574        mut last_error: FailoverError,
575        start_index: usize,
576    ) -> Result<(T, usize), FailoverError>
577    where
578        F: Fn(RootProvider<N>) -> Fut,
579        Fut: Future<Output = Result<T, RpcError<TransportErrorKind>>>,
580    {
581        let fallback_providers = self.fallback_providers();
582
583        debug!(
584            start_index = start_index,
585            total_fallbacks = fallback_providers.len(),
586            "Primary provider failed, attempting fallback providers"
587        );
588
589        let fallback_iter = fallback_providers.iter().enumerate().skip(start_index);
590        for (fallback_idx, provider) in fallback_iter {
591            trace!(
592                fallback_index = fallback_idx,
593                total_fallbacks = fallback_providers.len(),
594                "Attempting fallback provider"
595            );
596
597            match self.try_provider_with_timeout(provider, &operation).await {
598                Ok(value) => {
599                    info!(
600                        fallback_index = fallback_idx,
601                        total_fallbacks = fallback_providers.len(),
602                        "Switched to fallback provider"
603                    );
604                    return Ok((value, fallback_idx));
605                }
606                Err(e) => {
607                    warn!(
608                        fallback_index = fallback_idx,
609                        error = %e,
610                        "Fallback provider failed"
611                    );
612                    last_error = e;
613                }
614            }
615        }
616
617        error!(attempted_providers = fallback_providers.len() + 1, "All providers exhausted");
618
619        Err(last_error)
620    }
621
622    /// Try executing an operation with a specific provider with retry and timeout.
623    pub(crate) fn try_provider_with_timeout<T, F, Fut>(
624        &self,
625        provider: &RootProvider<N>,
626        operation: F,
627    ) -> impl Future<Output = Result<T, FailoverError>>
628    where
629        F: Fn(RootProvider<N>) -> Fut,
630        Fut: Future<Output = Result<T, RpcError<TransportErrorKind>>>,
631    {
632        let retry_strategy = ExponentialBuilder::default()
633            .with_max_times(self.max_retries())
634            .with_min_delay(self.min_delay());
635
636        let call_timeout = self.call_timeout();
637        let provider = provider.clone();
638
639        async move {
640            timeout(
641                call_timeout,
642                (|| operation(provider.clone()))
643                    .retry(retry_strategy)
644                    .when(|e| match e {
645                        // Check if the error is explicitly marked as retryable
646                        RpcError::ErrorResp(err_resp) if err_resp.is_retry_err() => true,
647                        // Check our custom non-retryable error classification
648                        // TODO: check if this can be omitted once https://github.com/OpenZeppelin/Robust-Provider/issues/12 is implemented
649                        RpcError::ErrorResp(err_resp) => {
650                            is_retryable_error(err_resp.code, err_resp.message.as_ref())
651                        }
652                        // Transport errors have their own retry logic
653                        RpcError::Transport(tr_err) => tr_err.is_retry_err(),
654                        // Default to retrying unknown errors
655                        _ => true,
656                    })
657                    .sleep(tokio::time::sleep),
658            )
659            .await
660            .map_err(FailoverError::from)?
661            .map_err(FailoverError::from)
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use std::sync::atomic::{AtomicUsize, Ordering};
669
670    use alloy::{
671        node_bindings::Anvil,
672        providers::{ProviderBuilder, WsConnect},
673    };
674    use tokio::time::sleep;
675
676    use crate::{
677        DEFAULT_RECONNECT_INTERVAL, DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY,
678        DEFAULT_SUBSCRIPTION_TIMEOUT, Error, RobustProvider, RobustProviderBuilder,
679    };
680
681    use super::*;
682
683    fn test_provider(timeout: u64, max_retries: usize, min_delay: u64) -> RobustProvider {
684        RobustProvider {
685            primary_provider: RootProvider::new_http("http://localhost:8545".parse().unwrap()),
686            fallback_providers: vec![],
687            call_timeout: Duration::from_millis(timeout),
688            subscription_timeout: DEFAULT_SUBSCRIPTION_TIMEOUT,
689            max_retries,
690            min_delay: Duration::from_millis(min_delay),
691            reconnect_interval: DEFAULT_RECONNECT_INTERVAL,
692            subscription_buffer_capacity: DEFAULT_SUBSCRIPTION_BUFFER_CAPACITY,
693            #[cfg(feature = "http-subscription")]
694            poll_interval: crate::DEFAULT_POLL_INTERVAL,
695            #[cfg(feature = "http-subscription")]
696            allow_http_subscriptions: false,
697        }
698    }
699
700    #[tokio::test]
701    async fn test_retry_with_timeout_succeeds_on_first_attempt() {
702        let provider = test_provider(100, 3, 10);
703
704        let call_count = AtomicUsize::new(0);
705
706        let result = provider
707            .try_operation_with_failover(|_| async {
708                call_count.fetch_add(1, Ordering::SeqCst);
709                let count = call_count.load(Ordering::SeqCst);
710                Ok(count)
711            })
712            .await;
713
714        assert!(matches!(result, Ok(1)));
715    }
716
717    #[tokio::test]
718    async fn test_retry_with_timeout_retries_on_error() {
719        let provider = test_provider(100, 3, 10);
720
721        let call_count = AtomicUsize::new(0);
722
723        let result = provider
724            .try_operation_with_failover(|_| async {
725                call_count.fetch_add(1, Ordering::SeqCst);
726                let count = call_count.load(Ordering::SeqCst);
727                match count {
728                    3 => Ok(count),
729                    // retriable error
730                    _ => Err(TransportErrorKind::custom_str("429 Too Many Requests")),
731                }
732            })
733            .await;
734
735        assert!(matches!(result, Ok(3)));
736    }
737
738    #[tokio::test]
739    async fn test_retry_with_timeout_fails_after_max_retries() {
740        let provider = test_provider(100, 2, 10);
741
742        let call_count = AtomicUsize::new(0);
743
744        let result: Result<(), FailoverError> = provider
745            .try_operation_with_failover(|_| async {
746                call_count.fetch_add(1, Ordering::SeqCst);
747                // retriable error
748                Err(TransportErrorKind::custom_str("429 Too Many Requests"))
749            })
750            .await;
751
752        assert!(matches!(result, Err(FailoverError::RpcError(_))));
753        assert_eq!(call_count.load(Ordering::SeqCst), 3);
754    }
755
756    #[tokio::test]
757    async fn test_retry_with_timeout_respects_call_timeout() {
758        let call_timeout = 50;
759        let provider = test_provider(call_timeout, 10, 1);
760
761        let result = provider
762            .try_operation_with_failover(move |_provider| async move {
763                sleep(Duration::from_millis(call_timeout + 10)).await;
764                Ok(42)
765            })
766            .await;
767
768        assert!(matches!(result, Err(FailoverError::Timeout)));
769    }
770
771    #[tokio::test]
772    async fn test_subscribe_fails_when_all_providers_lack_pubsub() -> anyhow::Result<()> {
773        let anvil = Anvil::new().try_spawn()?;
774
775        let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url());
776
777        let robust = RobustProviderBuilder::new(http_provider.clone())
778            .fallback(http_provider)
779            .call_timeout(Duration::from_secs(5))
780            .min_delay(Duration::from_millis(100))
781            .build()
782            .await?;
783
784        let err = robust.subscribe_blocks().await.unwrap_err();
785
786        assert!(
787            matches!(
788                err,
789                Error::RpcError(RpcError::Transport(TransportErrorKind::PubsubUnavailable))
790            ),
791            "expected TransportErrorKind::PubsubUnavailable error type, got: {err:?}"
792        );
793
794        Ok(())
795    }
796
797    #[tokio::test]
798    async fn test_subscribe_succeeds_if_primary_provider_lacks_pubsub_but_fallback_supports_it()
799    -> anyhow::Result<()> {
800        let anvil = Anvil::new().try_spawn()?;
801
802        let http_provider = ProviderBuilder::new().connect_http(anvil.endpoint_url());
803        let ws_provider = ProviderBuilder::new()
804            .connect_ws(WsConnect::new(anvil.ws_endpoint_url().as_str()))
805            .await?;
806
807        let robust = RobustProviderBuilder::fragile(http_provider)
808            .fallback(ws_provider)
809            .call_timeout(Duration::from_secs(5))
810            .build()
811            .await?;
812
813        let result = robust.subscribe_blocks().await;
814        assert!(result.is_ok());
815
816        Ok(())
817    }
818}