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}