Skip to main content

semioscan/provider/
pool.rs

1// SPDX-FileCopyrightText: 2025 Semiotic AI, Inc.
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Provider connection pooling for high-throughput scenarios
6//!
7//! This module provides thread-safe provider pooling that allows reusing
8//! provider connections across multiple concurrent operations.
9//!
10//! # Overview
11//!
12//! The [`ProviderPool`] maintains a collection of providers indexed by chain,
13//! enabling efficient connection reuse without creating new providers for each
14//! operation. This is particularly useful for:
15//!
16//! - Multi-chain applications that query many chains concurrently
17//! - High-throughput indexing or analytics workloads
18//! - Long-running services that process blocks continuously
19//!
20//! # Examples
21//!
22//! ## Static Pool Initialization
23//!
24//! For applications that know their chains at startup, use a static pool:
25//!
26//! ```rust,ignore
27//! use semioscan::provider::{ProviderPool, ProviderPoolBuilder};
28//! use alloy_chains::NamedChain;
29//! use std::sync::LazyLock;
30//!
31//! // Static pool initialized once on first access
32//! static PROVIDERS: LazyLock<ProviderPool> = LazyLock::new(|| {
33//!     ProviderPoolBuilder::new()
34//!         .add_chain(NamedChain::Mainnet, "https://eth.llamarpc.com")
35//!         .add_chain(NamedChain::Base, "https://mainnet.base.org")
36//!         .with_rate_limit(10)
37//!         .build()
38//!         .expect("Failed to create provider pool")
39//! });
40//!
41//! async fn get_block_number(chain: NamedChain) -> u64 {
42//!     let provider = PROVIDERS.get(chain).expect("Chain not configured");
43//!     provider.get_block_number().await.unwrap()
44//! }
45//! ```
46//!
47//! ## Dynamic Pool with Lazy Loading
48//!
49//! For applications that discover chains at runtime:
50//!
51//! ```rust,ignore
52//! use semioscan::provider::ProviderPool;
53//! use alloy_chains::NamedChain;
54//!
55//! let mut pool = ProviderPool::new();
56//!
57//! // Add providers as needed
58//! pool.add(NamedChain::Mainnet, "https://eth.llamarpc.com", None)?;
59//! pool.add(NamedChain::Base, "https://mainnet.base.org", Some(10))?;
60//!
61//! // Access providers
62//! if let Some(provider) = pool.get(NamedChain::Mainnet) {
63//!     let block = provider.get_block_number().await?;
64//! }
65//! ```
66//!
67//! ## With Preset Configurations
68//!
69//! ```rust,ignore
70//! use semioscan::provider::{ProviderPool, ChainEndpoint};
71//!
72//! let endpoints = vec![
73//!     ChainEndpoint::mainnet("https://eth.llamarpc.com"),
74//!     ChainEndpoint::base("https://mainnet.base.org"),
75//!     ChainEndpoint::optimism("https://mainnet.optimism.io"),
76//! ];
77//!
78//! let pool = ProviderPool::from_endpoints(endpoints, Some(10))?;
79//! ```
80
81use alloy_chains::NamedChain;
82use alloy_network::AnyNetwork;
83use alloy_provider::RootProvider;
84use std::collections::HashMap;
85use std::sync::{Arc, RwLock};
86use std::time::Duration;
87use tracing::{debug, info, warn};
88
89use crate::config::policy::RpcPolicy;
90use crate::errors::RpcError;
91
92use super::config::ProviderConfig;
93use super::factory::build_http_client;
94
95/// Type alias for a pooled provider using `AnyNetwork`
96pub type PooledProvider = Arc<RootProvider<AnyNetwork>>;
97
98/// A thread-safe pool of providers indexed by chain
99///
100/// The pool uses read-write locks for efficient concurrent access:
101/// - Multiple readers can access providers simultaneously
102/// - Writers acquire exclusive access when adding new providers
103///
104/// # Thread Safety
105///
106/// The pool is safe to share across threads via `Arc<ProviderPool>` or
107/// by storing it in a static variable with `LazyLock`.
108#[derive(Debug, Default)]
109pub struct ProviderPool {
110    /// Map of chain to provider
111    providers: RwLock<HashMap<NamedChain, PooledProvider>>,
112    /// Default rate limit for new providers (requests per second)
113    default_rate_limit: Option<u32>,
114    /// Default per-request timeout for new providers.
115    /// When neither this nor the per-endpoint `timeout` is set, the
116    /// underlying transport's default timeout applies.
117    default_timeout: Option<Duration>,
118}
119
120impl ProviderPool {
121    /// Create a new empty provider pool
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            providers: RwLock::new(HashMap::new()),
126            default_rate_limit: None,
127            default_timeout: None,
128        }
129    }
130
131    /// Create a pool with default rate limit for new providers
132    #[must_use]
133    pub fn with_defaults(rate_limit: Option<u32>) -> Self {
134        Self {
135            providers: RwLock::new(HashMap::new()),
136            default_rate_limit: rate_limit,
137            default_timeout: None,
138        }
139    }
140
141    /// Set the default per-request timeout used when an endpoint does not
142    /// override its own.
143    #[must_use]
144    pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
145        self.default_timeout = Some(timeout);
146        self
147    }
148
149    /// Create a pool from a list of chain endpoints
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if any endpoint URL is invalid
154    pub fn from_endpoints(
155        endpoints: Vec<ChainEndpoint>,
156        rate_limit: Option<u32>,
157    ) -> Result<Self, RpcError> {
158        let pool = Self::with_defaults(rate_limit);
159        for endpoint in endpoints {
160            pool.add_endpoint(&endpoint)?;
161        }
162        Ok(pool)
163    }
164
165    /// Add a provider for a specific chain
166    ///
167    /// If a provider already exists for this chain, it will be replaced.
168    ///
169    /// # Arguments
170    ///
171    /// * `chain` - The chain to add the provider for
172    /// * `url` - The RPC endpoint URL
173    /// * `rate_limit` - Optional rate limit in requests per second
174    ///
175    /// The per-request timeout is taken from the pool's default
176    /// (see [`with_default_timeout`](Self::with_default_timeout)).
177    /// Use [`add_endpoint`](Self::add_endpoint) to set a per-chain timeout
178    /// or minimum-delay pacing.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if the URL is invalid
183    pub fn add(
184        &self,
185        chain: NamedChain,
186        url: &str,
187        rate_limit: Option<u32>,
188    ) -> Result<(), RpcError> {
189        self.add_inner(chain, url, rate_limit, None, None)
190    }
191
192    /// Add a chain endpoint to the pool.
193    ///
194    /// Uses the endpoint's `rate_limit`, `timeout`, and `min_delay` when
195    /// set, otherwise falls back to the pool defaults.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the endpoint URL is invalid.
200    pub fn add_endpoint(&self, endpoint: &ChainEndpoint) -> Result<(), RpcError> {
201        self.add_inner(
202            endpoint.chain,
203            &endpoint.url,
204            endpoint.rate_limit,
205            endpoint.timeout,
206            endpoint.min_delay,
207        )
208    }
209
210    fn add_inner(
211        &self,
212        chain: NamedChain,
213        url: &str,
214        rate_limit: Option<u32>,
215        timeout: Option<Duration>,
216        min_delay: Option<Duration>,
217    ) -> Result<(), RpcError> {
218        let provider = create_pooled_provider(
219            url,
220            rate_limit.or(self.default_rate_limit),
221            timeout.or(self.default_timeout),
222            min_delay,
223        )?;
224
225        let mut providers = self.providers.write().map_err(|_| {
226            RpcError::ProviderConnectionFailed("Provider pool lock poisoned".to_string())
227        })?;
228
229        if providers.contains_key(&chain) {
230            debug!(chain = ?chain, "Replacing existing provider");
231        } else {
232            info!(chain = ?chain, url = url, "Added provider to pool");
233        }
234
235        providers.insert(chain, Arc::new(provider));
236        Ok(())
237    }
238
239    /// Get a provider for a specific chain
240    ///
241    /// Returns `None` if no provider is configured for the chain.
242    #[must_use]
243    pub fn get(&self, chain: NamedChain) -> Option<PooledProvider> {
244        self.providers
245            .read()
246            .ok()
247            .and_then(|providers| providers.get(&chain).cloned())
248    }
249
250    /// Get a provider for a chain, or add it if not present
251    ///
252    /// This is useful for lazy initialization of providers.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if the URL is invalid and the provider needs to be created
257    pub fn get_or_add(
258        &self,
259        chain: NamedChain,
260        url: &str,
261        rate_limit: Option<u32>,
262    ) -> Result<PooledProvider, RpcError> {
263        // Try read lock first for better concurrency
264        if let Some(provider) = self.get(chain) {
265            return Ok(provider);
266        }
267
268        // Provider not found, need to add it
269        self.add(chain, url, rate_limit)?;
270        self.get(chain).ok_or_else(|| {
271            RpcError::ProviderConnectionFailed("Failed to retrieve newly added provider".into())
272        })
273    }
274
275    /// Remove a provider from the pool
276    ///
277    /// Returns the removed provider if it existed.
278    pub fn remove(&self, chain: NamedChain) -> Option<PooledProvider> {
279        self.providers
280            .write()
281            .ok()
282            .and_then(|mut providers| providers.remove(&chain))
283    }
284
285    /// Check if a provider exists for a chain
286    #[must_use]
287    pub fn contains(&self, chain: NamedChain) -> bool {
288        self.providers
289            .read()
290            .ok()
291            .is_some_and(|providers| providers.contains_key(&chain))
292    }
293
294    /// Get the number of providers in the pool
295    #[must_use]
296    pub fn len(&self) -> usize {
297        self.providers
298            .read()
299            .map(|providers| providers.len())
300            .unwrap_or(0)
301    }
302
303    /// Check if the pool is empty
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.len() == 0
307    }
308
309    /// Get all configured chains
310    #[must_use]
311    pub fn chains(&self) -> Vec<NamedChain> {
312        self.providers
313            .read()
314            .map(|providers| providers.keys().copied().collect())
315            .unwrap_or_default()
316    }
317
318    /// Clear all providers from the pool
319    pub fn clear(&self) {
320        if let Ok(mut providers) = self.providers.write() {
321            providers.clear();
322            info!("Cleared all providers from pool");
323        }
324    }
325}
326
327/// Builder for creating provider pools with common configurations
328#[derive(Default)]
329pub struct ProviderPoolBuilder {
330    endpoints: Vec<ChainEndpoint>,
331    default_rate_limit: Option<u32>,
332    default_timeout: Option<Duration>,
333    rpc_policy_timeouts: HashMap<NamedChain, Duration>,
334    rpc_policy_min_delays: HashMap<NamedChain, Duration>,
335}
336
337impl ProviderPoolBuilder {
338    /// Create a new builder
339    #[must_use]
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Add a chain endpoint to the pool
345    #[must_use]
346    pub fn add_chain(mut self, chain: NamedChain, url: &str) -> Self {
347        self.endpoints.push(ChainEndpoint::new(chain, url));
348        self
349    }
350
351    /// Add a chain endpoint with a specific rate limit
352    #[must_use]
353    pub fn add_chain_with_rate_limit(
354        mut self,
355        chain: NamedChain,
356        url: &str,
357        rate_limit: u32,
358    ) -> Self {
359        self.endpoints
360            .push(ChainEndpoint::new(chain, url).with_rate_limit(rate_limit));
361        self
362    }
363
364    /// Add a chain endpoint with a specific per-request timeout
365    #[must_use]
366    pub fn add_chain_with_timeout(
367        mut self,
368        chain: NamedChain,
369        url: &str,
370        timeout: Duration,
371    ) -> Self {
372        self.endpoints
373            .push(ChainEndpoint::new(chain, url).with_timeout(timeout));
374        self
375    }
376
377    /// Add a fully-specified [`ChainEndpoint`] to the pool
378    #[must_use]
379    pub fn add_endpoint(mut self, endpoint: ChainEndpoint) -> Self {
380        self.endpoints.push(endpoint);
381        self
382    }
383
384    /// Set the default requests-per-second budget applied to every endpoint
385    /// in the pool that does not carry its own [`ChainEndpoint::rate_limit`].
386    ///
387    /// **Interaction with [`with_rpc_policy`](Self::with_rpc_policy).** The
388    /// per-second budget and a policy-supplied `rate_limit_delay` are not
389    /// stackable on the wire — each endpoint installs at most one
390    /// rate-limit layer. A chain that ends up with both a requests-per-second
391    /// budget (from this setter or [`ChainEndpoint::with_rate_limit`]) and a
392    /// minimum gap (from [`ChainEndpoint::with_min_delay`] or a policy
393    /// `rate_limit_delay`) causes [`build`](Self::build) to return
394    /// [`RpcError::ConflictingRateLimit`](crate::errors::RpcError::ConflictingRateLimit)
395    /// rather than silently dropping one axis. If the per-chain minimum gap
396    /// is the one that matters, set the per-second budget only on the
397    /// endpoints that need it via [`ChainEndpoint::with_rate_limit`] instead
398    /// of this pool-wide default, and the policy delay will apply to the
399    /// remaining chains.
400    #[must_use]
401    pub fn with_rate_limit(mut self, requests_per_second: u32) -> Self {
402        self.default_rate_limit = Some(requests_per_second);
403        self
404    }
405
406    /// Set the default per-request timeout for all providers
407    #[must_use]
408    pub fn with_timeout(mut self, timeout: Duration) -> Self {
409        self.default_timeout = Some(timeout);
410        self
411    }
412
413    /// Apply per-chain settings from an [`RpcPolicy`].
414    ///
415    /// The policy is evaluated for every endpoint that has already been
416    /// added to the builder; call this **after** all `add_chain*` /
417    /// `add_endpoint` calls for the policy to take effect. Endpoints added
418    /// after this call do **not** pick up the policy values — call
419    /// `with_rpc_policy` again to refresh.
420    ///
421    /// For each axis the policy supplies (`rpc_timeout`, `rate_limit_delay`),
422    /// a per-endpoint value always wins; the builder's `default_timeout`
423    /// is the fallback when neither the endpoint nor the policy supplies
424    /// a timeout for a chain. There is no pool-level default for the
425    /// minimum-delay pacing axis, so a chain with no endpoint `min_delay`
426    /// and no policy `rate_limit_delay` simply runs unpaced.
427    ///
428    /// **Interaction with [`with_rate_limit`](Self::with_rate_limit) and
429    /// [`ChainEndpoint::with_rate_limit`].** A policy's `rate_limit_delay`
430    /// and a requests-per-second budget on the same chain are not stackable
431    /// on the wire: each endpoint installs at most one rate-limit layer.
432    /// If `with_rate_limit(rps)` or an endpoint-level `rate_limit` is set
433    /// on a chain that the policy also supplies a `rate_limit_delay` for,
434    /// [`build`](Self::build) returns
435    /// [`RpcError::ConflictingRateLimit`](crate::errors::RpcError::ConflictingRateLimit)
436    /// rather than silently dropping one axis. To keep both knobs effective
437    /// across the pool, move the per-second budget off this pool-wide
438    /// default and onto the chains that need it via
439    /// [`ChainEndpoint::with_rate_limit`], leaving the policy delay in place
440    /// for the rest.
441    #[must_use]
442    pub fn with_rpc_policy<P: RpcPolicy>(mut self, policy: &P) -> Self {
443        for endpoint in &self.endpoints {
444            let cfg = policy.rpc_config(endpoint.chain);
445            self.rpc_policy_timeouts
446                .insert(endpoint.chain, cfg.rpc_timeout);
447            if let Some(delay) = cfg.rate_limit_delay {
448                self.rpc_policy_min_delays.insert(endpoint.chain, delay);
449            }
450        }
451        self
452    }
453
454    /// Build the provider pool
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if any endpoint URL is invalid, or if any chain
459    /// ends up with both a requests-per-second budget and a minimum-delay
460    /// gap set after composing endpoint, builder, and policy values
461    /// ([`RpcError::ConflictingRateLimit`](crate::errors::RpcError::ConflictingRateLimit)).
462    pub fn build(self) -> Result<ProviderPool, RpcError> {
463        let pool = ProviderPool::with_defaults(self.default_rate_limit);
464        let pool = match self.default_timeout {
465            Some(t) => pool.with_default_timeout(t),
466            None => pool,
467        };
468
469        for endpoint in &self.endpoints {
470            let policy_timeout = self.rpc_policy_timeouts.get(&endpoint.chain).copied();
471            let policy_min_delay = self.rpc_policy_min_delays.get(&endpoint.chain).copied();
472            let effective = ChainEndpoint {
473                chain: endpoint.chain,
474                url: endpoint.url.clone(),
475                rate_limit: endpoint.rate_limit.or(self.default_rate_limit),
476                timeout: endpoint.timeout.or(policy_timeout),
477                min_delay: endpoint.min_delay.or(policy_min_delay),
478            };
479            pool.add_endpoint(&effective)?;
480        }
481
482        Ok(pool)
483    }
484}
485
486/// Configuration for a chain endpoint.
487///
488/// Construct via [`ChainEndpoint::new`] (or one of the preset constructors
489/// such as [`ChainEndpoint::mainnet`]) and the `with_*` setters. The struct
490/// is `#[non_exhaustive]` so additional optional fields can be added in
491/// future releases without forcing struct-literal callers to update.
492#[derive(Debug, Clone)]
493#[non_exhaustive]
494pub struct ChainEndpoint {
495    /// The chain this endpoint serves
496    pub chain: NamedChain,
497    /// The RPC endpoint URL
498    pub url: String,
499    /// Optional rate limit override for this specific chain
500    pub rate_limit: Option<u32>,
501    /// Optional per-request timeout override for this specific chain.
502    /// When `None`, the pool's default timeout (if any) is used.
503    pub timeout: Option<Duration>,
504    /// Optional minimum spacing between requests for this specific chain.
505    /// When set, the underlying transport installs a minimum-delay layer
506    /// that guarantees at least this gap between consecutive RPC calls.
507    /// When `None`, the policy's per-chain `rate_limit_delay` (if any)
508    /// supplies the value at build time.
509    pub min_delay: Option<Duration>,
510}
511
512impl ChainEndpoint {
513    /// Create a new chain endpoint
514    #[must_use]
515    pub fn new(chain: NamedChain, url: impl Into<String>) -> Self {
516        Self {
517            chain,
518            url: url.into(),
519            rate_limit: None,
520            timeout: None,
521            min_delay: None,
522        }
523    }
524
525    /// Create a new chain endpoint with rate limiting
526    #[must_use]
527    pub fn with_rate_limit(mut self, rate_limit: u32) -> Self {
528        self.rate_limit = Some(rate_limit);
529        self
530    }
531
532    /// Override the per-request timeout for this chain
533    #[must_use]
534    pub fn with_timeout(mut self, timeout: Duration) -> Self {
535        self.timeout = Some(timeout);
536        self
537    }
538
539    /// Override the minimum spacing between requests for this chain.
540    ///
541    /// Mirrors [`ProviderConfig::with_min_delay`](crate::provider::ProviderConfig::with_min_delay):
542    /// the underlying transport installs a layer that guarantees at least
543    /// `delay` between consecutive RPC calls.
544    ///
545    /// # Panics
546    ///
547    /// Panics if `delay` is [`Duration::ZERO`]. See
548    /// [`ProviderConfig::with_min_delay`](crate::provider::ProviderConfig::with_min_delay)
549    /// for the reasoning and how to express "no pacing" for an endpoint
550    /// instead.
551    #[must_use]
552    #[track_caller]
553    pub fn with_min_delay(mut self, delay: Duration) -> Self {
554        super::assert_nonzero_min_delay(delay);
555        self.min_delay = Some(delay);
556        self
557    }
558
559    /// Create an Ethereum mainnet endpoint
560    #[must_use]
561    pub fn mainnet(url: impl Into<String>) -> Self {
562        Self::new(NamedChain::Mainnet, url)
563    }
564
565    /// Create a Base mainnet endpoint
566    #[must_use]
567    pub fn base(url: impl Into<String>) -> Self {
568        Self::new(NamedChain::Base, url)
569    }
570
571    /// Create an Optimism mainnet endpoint
572    #[must_use]
573    pub fn optimism(url: impl Into<String>) -> Self {
574        Self::new(NamedChain::Optimism, url)
575    }
576
577    /// Create an Arbitrum One endpoint
578    #[must_use]
579    pub fn arbitrum(url: impl Into<String>) -> Self {
580        Self::new(NamedChain::Arbitrum, url)
581    }
582
583    /// Create a Polygon mainnet endpoint
584    #[must_use]
585    pub fn polygon(url: impl Into<String>) -> Self {
586        Self::new(NamedChain::Polygon, url)
587    }
588
589    /// Create a Sepolia testnet endpoint
590    #[must_use]
591    pub fn sepolia(url: impl Into<String>) -> Self {
592        Self::new(NamedChain::Sepolia, url)
593    }
594}
595
596/// Create a pooled provider with optional rate limiting and pacing.
597///
598/// Routes through the shared HTTP client builder so the
599/// `(rate_limit_per_second, min_delay, timeout)` dispatch matrix matches
600/// every other HTTP provider this crate hands out. Returns a bare
601/// `RootProvider` without fillers, as fillers are application-specific
602/// and should be added by the consumer if needed.
603///
604/// Note: RPC request/response logging is handled natively by alloy's transport
605/// layer at DEBUG/TRACE level.
606fn create_pooled_provider(
607    url: &str,
608    rate_limit: Option<u32>,
609    timeout: Option<Duration>,
610    min_delay: Option<Duration>,
611) -> Result<RootProvider<AnyNetwork>, RpcError> {
612    let mut config = ProviderConfig::new(url).with_rate_limit_opt(rate_limit);
613    if let Some(t) = timeout {
614        config = config.with_timeout(t);
615    }
616    if let Some(d) = min_delay {
617        config = config.with_min_delay(d);
618    }
619
620    let client = build_http_client(config).inspect_err(|e| {
621        warn!(url = url, error = ?e, "Failed to build pooled provider");
622    })?;
623
624    Ok(RootProvider::<AnyNetwork>::new(client))
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_pool_new() {
633        let pool = ProviderPool::new();
634        assert!(pool.is_empty());
635        assert_eq!(pool.len(), 0);
636    }
637
638    #[test]
639    fn test_pool_with_defaults() {
640        let pool = ProviderPool::with_defaults(Some(10));
641        assert_eq!(pool.default_rate_limit, Some(10));
642    }
643
644    #[test]
645    fn test_chain_endpoint_constructors() {
646        let endpoint = ChainEndpoint::mainnet("https://eth.llamarpc.com");
647        assert_eq!(endpoint.chain, NamedChain::Mainnet);
648        assert_eq!(endpoint.url, "https://eth.llamarpc.com");
649        assert!(endpoint.rate_limit.is_none());
650
651        let endpoint = ChainEndpoint::base("https://mainnet.base.org").with_rate_limit(5);
652        assert_eq!(endpoint.chain, NamedChain::Base);
653        assert_eq!(endpoint.rate_limit, Some(5));
654    }
655
656    #[test]
657    fn test_pool_builder() {
658        let builder = ProviderPoolBuilder::new()
659            .add_chain(NamedChain::Mainnet, "https://eth.llamarpc.com")
660            .add_chain_with_rate_limit(NamedChain::Base, "https://mainnet.base.org", 5)
661            .with_rate_limit(10);
662
663        assert_eq!(builder.endpoints.len(), 2);
664        assert_eq!(builder.default_rate_limit, Some(10));
665    }
666
667    #[test]
668    fn test_pool_contains_and_chains() {
669        let pool = ProviderPool::new();
670
671        // Add a provider (using a valid URL format)
672        let result = pool.add(NamedChain::Mainnet, "https://eth.llamarpc.com", None);
673        assert!(result.is_ok());
674
675        assert!(pool.contains(NamedChain::Mainnet));
676        assert!(!pool.contains(NamedChain::Base));
677
678        let chains = pool.chains();
679        assert_eq!(chains.len(), 1);
680        assert!(chains.contains(&NamedChain::Mainnet));
681    }
682
683    #[test]
684    fn test_pool_remove() {
685        let pool = ProviderPool::new();
686        pool.add(NamedChain::Mainnet, "https://eth.llamarpc.com", None)
687            .unwrap();
688
689        assert!(pool.contains(NamedChain::Mainnet));
690
691        let removed = pool.remove(NamedChain::Mainnet);
692        assert!(removed.is_some());
693        assert!(!pool.contains(NamedChain::Mainnet));
694
695        // Removing again should return None
696        let removed_again = pool.remove(NamedChain::Mainnet);
697        assert!(removed_again.is_none());
698    }
699
700    #[test]
701    fn test_pool_clear() {
702        let pool = ProviderPool::new();
703        pool.add(NamedChain::Mainnet, "https://eth.llamarpc.com", None)
704            .unwrap();
705        pool.add(NamedChain::Base, "https://mainnet.base.org", None)
706            .unwrap();
707
708        assert_eq!(pool.len(), 2);
709
710        pool.clear();
711        assert!(pool.is_empty());
712    }
713
714    #[test]
715    fn test_invalid_url() {
716        let pool = ProviderPool::new();
717        let result = pool.add(NamedChain::Mainnet, "not a valid url", None);
718        assert!(result.is_err());
719    }
720
721    /// Zero-duration `min_delay` is rejected at the endpoint builder call
722    /// site. Pins the contract documented on
723    /// [`ChainEndpoint::with_min_delay`]'s `# Panics` section: the
724    /// token-bucket layer it ultimately feeds cannot represent a zero
725    /// period, so the setter fails fast at the operator's call line
726    /// rather than letting `Some(Duration::ZERO)` propagate to a later
727    /// pool-build panic with a useless backtrace.
728    #[test]
729    #[should_panic(expected = "min_delay must be > 0")]
730    fn test_chain_endpoint_with_min_delay_rejects_zero() {
731        let _ = ChainEndpoint::new(NamedChain::Mainnet, "https://eth.llamarpc.com")
732            .with_min_delay(Duration::ZERO);
733    }
734
735    #[test]
736    fn with_rpc_policy_only_covers_chains_added_before_it() {
737        use crate::config::policy::RpcConfig;
738
739        struct FixedPolicy {
740            timeout: Duration,
741            rate_limit_delay: Option<Duration>,
742        }
743        impl RpcPolicy for FixedPolicy {
744            fn rpc_config(&self, _: NamedChain) -> RpcConfig {
745                RpcConfig {
746                    rpc_timeout: self.timeout,
747                    rate_limit_delay: self.rate_limit_delay,
748                }
749            }
750        }
751
752        let policy = FixedPolicy {
753            timeout: Duration::from_secs(5),
754            rate_limit_delay: Some(Duration::from_millis(250)),
755        };
756
757        let before = ProviderPoolBuilder::new()
758            .add_chain(NamedChain::Mainnet, "http://localhost:8545")
759            .with_rpc_policy(&policy);
760        assert_eq!(
761            before
762                .rpc_policy_timeouts
763                .get(&NamedChain::Mainnet)
764                .copied(),
765            Some(Duration::from_secs(5)),
766            "policy timeout must apply when chain is added before with_rpc_policy",
767        );
768        assert_eq!(
769            before
770                .rpc_policy_min_delays
771                .get(&NamedChain::Mainnet)
772                .copied(),
773            Some(Duration::from_millis(250)),
774            "policy rate_limit_delay must apply when chain is added before with_rpc_policy",
775        );
776
777        let after = ProviderPoolBuilder::new()
778            .with_rpc_policy(&policy)
779            .add_chain(NamedChain::Mainnet, "http://localhost:8545");
780        assert!(
781            !after.rpc_policy_timeouts.contains_key(&NamedChain::Mainnet),
782            "policy timeout must not apply when chain is added after with_rpc_policy",
783        );
784        assert!(
785            !after
786                .rpc_policy_min_delays
787                .contains_key(&NamedChain::Mainnet),
788            "policy rate_limit_delay must not apply when chain is added after with_rpc_policy",
789        );
790    }
791}