Skip to main content

alloy_transport_balancer/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3
4#[cfg(feature = "batching")]
5mod batching;
6#[cfg(feature = "balancer")]
7mod client_pool;
8#[cfg(feature = "balancer")]
9mod request_rate;
10#[cfg(feature = "balancer")]
11mod throttle;
12
13#[cfg(feature = "batching")]
14pub use batching::{BatchingConfig, BatchingTransport};
15#[cfg(feature = "balancer")]
16pub use client_pool::{HttpClientConfig, set_default_http_client_config};
17#[cfg(feature = "balancer")]
18pub use throttle::{
19    DomainThrottleSnapshot, DomainThrottleState, ThrottleConfig, domain_throttle,
20    extract_rate_limit_domain, log_throttle_summary, max_domain_backoff, pre_request_delay,
21    record_batch_rejection, record_rate_limit, record_success, set_default_throttle_config,
22    throttle_snapshot, weighted_domain_backoff,
23};
24
25#[cfg(feature = "balancer")]
26use alloy_json_rpc::{RequestPacket, ResponsePacket};
27#[cfg(feature = "balancer")]
28use alloy_transport::{RpcError, TransportError, TransportErrorKind, TransportFut};
29#[cfg(feature = "balancer")]
30use alloy_transport_http::Http;
31#[cfg(feature = "balancer")]
32use client_pool::shared_client_for_domain;
33#[cfg(feature = "balancer")]
34use request_rate::{RequestRateFallback, RequestRateLimiter, RequestRateReservation};
35#[cfg(feature = "balancer")]
36use reqwest::Url;
37use std::fmt;
38#[cfg(feature = "balancer")]
39use std::{
40    collections::HashMap,
41    sync::{
42        Arc, RwLock,
43        atomic::{AtomicU64, Ordering},
44    },
45    task::{Context, Poll},
46    time::Duration,
47};
48#[cfg(feature = "balancer")]
49use throttle::record_skip;
50#[cfg(feature = "balancer")]
51use tokio::sync::Semaphore;
52#[cfg(feature = "balancer")]
53use tokio::time::sleep;
54#[cfg(feature = "balancer")]
55use tower::Service;
56#[cfg(feature = "balancer")]
57use tracing::{debug, warn};
58
59/// Stable, non-secret identity for one configured RPC provider endpoint.
60///
61/// Endpoint identities let callers route follow-up reads back to the provider
62/// that announced a block or event. They should be short operator-defined
63/// labels such as `quicknode` or `alchemy`, never URLs or credentials.
64#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
65pub struct EndpointId(String);
66
67impl EndpointId {
68    /// Construct a non-empty endpoint identity.
69    ///
70    /// # Panics
71    ///
72    /// Panics when `value` is empty or contains leading/trailing whitespace.
73    pub fn new(value: impl Into<String>) -> Self {
74        let value = value.into();
75        assert!(!value.is_empty(), "endpoint id must not be empty");
76        assert_eq!(
77            value.trim(),
78            value,
79            "endpoint id must not contain outer whitespace"
80        );
81        Self(value)
82    }
83
84    /// Borrow the configured identity.
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl fmt::Debug for EndpointId {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.debug_tuple("EndpointId").field(&self.as_str()).finish()
93    }
94}
95
96impl fmt::Display for EndpointId {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str(self.as_str())
99    }
100}
101
102impl From<&str> for EndpointId {
103    fn from(value: &str) -> Self {
104        Self::new(value)
105    }
106}
107
108impl From<String> for EndpointId {
109    fn from(value: String) -> Self {
110        Self::new(value)
111    }
112}
113
114/// Scoped endpoint ordering for a cloned transport handle.
115///
116/// Routing policy is deliberately local to the handle. An event consumer can
117/// prefer or pin its announcing provider without changing the ordering used by
118/// unrelated requests sharing the same underlying connection pools.
119#[derive(Clone, Debug, Default, PartialEq, Eq)]
120#[cfg(feature = "balancer")]
121pub enum RoutePolicy {
122    /// Use normal weighted selection and cross-provider failover.
123    #[default]
124    Balanced,
125    /// Try the named endpoint first, then permit normal failover.
126    Prefer(EndpointId),
127    /// Send every attempt to the named endpoint and never fail over.
128    Require(EndpointId),
129}
130
131/// Error returned when a requested endpoint route cannot be constructed.
132#[derive(Clone, Debug, PartialEq, Eq)]
133#[cfg(feature = "balancer")]
134pub enum EndpointRouteError {
135    /// No configured endpoint has the requested identity.
136    UnknownEndpoint(EndpointId),
137    /// The endpoint exists but was not marked as Flashblocks-capable.
138    FlashblocksUnsupported(EndpointId),
139    /// The endpoint is declared Flashblocks-capable but has not passed its
140    /// current runtime liveness/capability preflight.
141    FlashblocksUnqualified(EndpointId),
142}
143
144#[cfg(feature = "balancer")]
145impl fmt::Display for EndpointRouteError {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::UnknownEndpoint(id) => write!(f, "unknown RPC endpoint id {id}"),
149            Self::FlashblocksUnsupported(id) => {
150                write!(f, "RPC endpoint {id} is not configured for Flashblocks")
151            }
152            Self::FlashblocksUnqualified(id) => {
153                write!(f, "RPC endpoint {id} has not passed Flashblocks preflight")
154            }
155        }
156    }
157}
158
159#[cfg(feature = "balancer")]
160impl std::error::Error for EndpointRouteError {}
161
162/// Latest runtime Flashblocks qualification for one declared endpoint.
163#[derive(Clone, Debug, PartialEq, Eq)]
164#[cfg(feature = "balancer")]
165pub enum FlashblocksQualificationState {
166    /// No preflight result has been recorded for the current process.
167    Unprobed,
168    /// Chain identity and provider-advertised capabilities were validated.
169    Qualified {
170        /// Chain id observed through the same provider lease.
171        chain_id: u64,
172        /// Opaque capability names returned by the provider, when available.
173        capabilities: Vec<String>,
174    },
175    /// The most recent preflight failed. A later successful probe can recover
176    /// the endpoint without rebuilding the balancer.
177    Ineligible {
178        /// Stable operator-facing failure reason; never an endpoint URL.
179        reason: String,
180    },
181}
182
183/// Result supplied by the component that performs the live WebSocket and
184/// pending-state preflight.
185#[derive(Clone, Debug, PartialEq, Eq)]
186#[cfg(feature = "balancer")]
187pub enum FlashblocksProbeOutcome {
188    /// All required checks passed.
189    Qualified {
190        /// Validated chain id.
191        chain_id: u64,
192        /// Opaque provider-advertised capabilities, if the RPC supports them.
193        capabilities: Vec<String>,
194    },
195    /// At least one required check failed.
196    Ineligible {
197        /// Stable operator-facing failure reason; never an endpoint URL.
198        reason: String,
199    },
200}
201
202/// Observable proactive request-rate state for one configured endpoint.
203#[derive(Clone, Debug, PartialEq, Eq)]
204#[cfg(feature = "balancer")]
205pub struct EndpointRequestRateSnapshot {
206    /// Stable operator identity, when configured.
207    pub endpoint_id: Option<EndpointId>,
208    /// Configured JSON-RPC call ceiling for a rolling one-second window.
209    pub max_requests_per_second: u32,
210    /// Calls admitted during the current rolling one-second window.
211    pub used_in_rolling_window: u32,
212    /// Calls still available during the current rolling one-second window.
213    pub remaining_in_rolling_window: u32,
214    /// Total JSON-RPC calls admitted since construction.
215    pub total_admitted_rpc_calls: u64,
216    /// Total packets redirected after this endpoint lacked immediate capacity.
217    pub total_capacity_rollovers: u64,
218    /// Total times callers waited for this endpoint's capacity.
219    pub total_capacity_waits: u64,
220}
221
222/// Observable cumulative probe metrics and latest qualification state.
223#[derive(Clone, Debug, PartialEq, Eq)]
224#[cfg(feature = "balancer")]
225pub struct FlashblocksQualificationSnapshot {
226    /// Total recorded probe attempts.
227    pub attempts: u64,
228    /// Successful probe attempts.
229    pub successes: u64,
230    /// Failed probe attempts.
231    pub failures: u64,
232    /// Latest result.
233    pub state: FlashblocksQualificationState,
234}
235
236#[derive(Clone, Debug)]
237#[cfg(feature = "balancer")]
238struct FlashblocksQualification {
239    attempts: u64,
240    successes: u64,
241    failures: u64,
242    state: FlashblocksQualificationState,
243}
244
245#[cfg(feature = "balancer")]
246impl Default for FlashblocksQualification {
247    fn default() -> Self {
248        Self {
249            attempts: 0,
250            successes: 0,
251            failures: 0,
252            state: FlashblocksQualificationState::Unprobed,
253        }
254    }
255}
256
257#[cfg(feature = "balancer")]
258impl FlashblocksQualification {
259    fn snapshot(&self) -> FlashblocksQualificationSnapshot {
260        FlashblocksQualificationSnapshot {
261            attempts: self.attempts,
262            successes: self.successes,
263            failures: self.failures,
264            state: self.state.clone(),
265        }
266    }
267}
268
269/// Relative weight for an endpoint in the load balancer.
270///
271/// Higher values route proportionally more traffic to an endpoint.
272/// The default weight of 100 provides headroom for relative adjustments
273/// (e.g., `Weight(150)` for a preferred provider, `Weight(50)` for a backup).
274///
275/// # Examples
276///
277/// ```
278/// use alloy_transport_balancer::Weight;
279///
280/// // Default weight of 100 for equal distribution
281/// let w = Weight::default();
282/// assert_eq!(w.0, 100);
283///
284/// // Convert from u32
285/// let w: Weight = 150.into();
286/// assert_eq!(w, Weight(150));
287/// ```
288#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
289#[cfg(feature = "balancer")]
290pub struct Weight(pub u32);
291
292#[cfg(feature = "balancer")]
293impl Default for Weight {
294    fn default() -> Self {
295        Self(100)
296    }
297}
298
299#[cfg(feature = "balancer")]
300impl From<u32> for Weight {
301    fn from(w: u32) -> Self {
302        Self(w)
303    }
304}
305
306/// Per-endpoint routing capabilities.
307///
308/// Request-size, in-flight, and request-rate limits let a heterogeneous provider
309/// pool keep a low-throughput fallback available without sending it work it
310/// cannot accept.
311#[derive(Clone, Debug)]
312#[cfg(feature = "balancer")]
313pub struct EndpointConfig {
314    /// Stable provider identity used for source-aware follow-up routing.
315    pub id: Option<EndpointId>,
316    /// RPC URL.
317    pub url: Url,
318    /// Relative traffic weight.
319    pub weight: Weight,
320    /// Maximum serialized JSON-RPC request bytes accepted by this endpoint.
321    /// `None` leaves request size unrestricted.
322    pub max_request_bytes: Option<usize>,
323    /// Maximum simultaneous requests routed to this endpoint. `None` leaves
324    /// concurrency unrestricted.
325    pub max_in_flight: Option<usize>,
326    /// Maximum JSON-RPC calls admitted to this endpoint in any rolling second.
327    /// A batch consumes one unit per member. `None` leaves the rate unrestricted.
328    pub max_requests_per_second: Option<u32>,
329    /// Whether this provider pair is expected to expose a complete Flashblocks
330    /// feed and matching preconfirmed state.
331    pub flashblocks: bool,
332}
333
334#[cfg(feature = "balancer")]
335impl EndpointConfig {
336    /// Construct an endpoint with unrestricted request size, concurrency, and rate.
337    pub const fn new(url: Url, weight: Weight) -> Self {
338        Self {
339            id: None,
340            url,
341            weight,
342            max_request_bytes: None,
343            max_in_flight: None,
344            max_requests_per_second: None,
345            flashblocks: false,
346        }
347    }
348
349    /// Attach a stable provider identity for source-aware routing.
350    pub fn with_id(mut self, id: impl Into<EndpointId>) -> Self {
351        self.id = Some(id.into());
352        self
353    }
354
355    /// Mark whether the provider pair supports Flashblocks.
356    pub const fn with_flashblocks(mut self, supported: bool) -> Self {
357        self.flashblocks = supported;
358        self
359    }
360
361    /// Set the largest serialized request this endpoint may receive.
362    pub const fn with_max_request_bytes(mut self, bytes: usize) -> Self {
363        self.max_request_bytes = Some(bytes);
364        self
365    }
366
367    /// Bound simultaneous requests sent to this endpoint.
368    pub const fn with_max_in_flight(mut self, max_in_flight: usize) -> Self {
369        self.max_in_flight = Some(max_in_flight);
370        self
371    }
372
373    /// Bound JSON-RPC calls admitted to this endpoint in any rolling second.
374    ///
375    /// A single request consumes one unit and a batch consumes one unit per
376    /// member. When the budget is exhausted, balanced and preferred routes try
377    /// another eligible endpoint before waiting for capacity.
378    pub const fn with_max_requests_per_second(mut self, limit: u32) -> Self {
379        self.max_requests_per_second = Some(limit);
380        self
381    }
382}
383
384#[cfg(feature = "balancer")]
385impl From<(Url, Weight)> for EndpointConfig {
386    fn from((url, weight): (Url, Weight)) -> Self {
387        Self::new(url, weight)
388    }
389}
390
391/// Configuration for the load balancer's retry and failover behavior.
392///
393/// # Examples
394///
395/// ```
396/// use alloy_transport_balancer::BalancerConfig;
397/// use std::time::Duration;
398///
399/// let config = BalancerConfig {
400///     max_retry_rounds: 5,
401///     initial_backoff: Duration::from_millis(200),
402///     ..Default::default()
403/// };
404/// ```
405#[derive(Clone, Debug)]
406#[cfg(feature = "balancer")]
407pub struct BalancerConfig {
408    /// Maximum number of full-rotation retry rounds after the initial attempt.
409    /// Total rotation attempts = 1 (initial) + max_retry_rounds. Default: 3.
410    pub max_retry_rounds: u32,
411    /// Initial backoff delay between retry rounds. Default: 100ms.
412    pub initial_backoff: Duration,
413    /// Maximum backoff delay between retry rounds. Default: 4s.
414    pub max_backoff: Duration,
415    /// Delay inserted between failovers on 429 responses. Default: 75ms.
416    pub rate_limit_failover_delay: Duration,
417    /// Domain throttle delay above which an endpoint is skipped. Default: 500ms.
418    pub heavy_throttle_threshold: Duration,
419}
420
421#[cfg(feature = "balancer")]
422impl Default for BalancerConfig {
423    fn default() -> Self {
424        Self {
425            max_retry_rounds: 3,
426            initial_backoff: Duration::from_millis(100),
427            max_backoff: Duration::from_secs(4),
428            rate_limit_failover_delay: Duration::from_millis(75),
429            heavy_throttle_threshold: Duration::from_millis(500),
430        }
431    }
432}
433
434/// Builder for [`LoadBalancedTransport`].
435///
436/// Allows configuring the balancer, throttle, and HTTP client behavior
437/// before constructing the transport.
438///
439/// # Example
440///
441/// ```
442/// use alloy_transport_balancer::{
443///     BalancerConfig, HttpClientConfig, LoadBalancedTransport, Weight,
444/// };
445/// use reqwest::Url;
446/// use std::time::Duration;
447///
448/// let transport = LoadBalancedTransport::builder(vec![
449///     (Url::parse("http://localhost:8545").unwrap(), Weight::default()),
450///     (Url::parse("http://localhost:8546").unwrap(), Weight::default()),
451/// ])
452/// .config(BalancerConfig {
453///     max_retry_rounds: 5,
454///     ..Default::default()
455/// })
456/// .http_client_config(HttpClientConfig {
457///     request_timeout: Duration::from_secs(10),
458///     gzip: true,
459///     ..Default::default()
460/// })
461/// .build();
462/// ```
463#[cfg(feature = "balancer")]
464pub struct LoadBalancedTransportBuilder {
465    endpoints: Vec<EndpointConfig>,
466    config: BalancerConfig,
467    throttle_config: Option<ThrottleConfig>,
468    http_client_config: Option<HttpClientConfig>,
469}
470
471#[cfg(feature = "balancer")]
472impl LoadBalancedTransportBuilder {
473    /// Set the balancer retry/failover configuration.
474    pub const fn config(mut self, config: BalancerConfig) -> Self {
475        self.config = config;
476        self
477    }
478
479    /// Set the domain throttle configuration.
480    ///
481    /// This sets the process-wide default for newly created domains.
482    /// Already-registered domains keep their existing config.
483    pub fn throttle_config(mut self, config: ThrottleConfig) -> Self {
484        self.throttle_config = Some(config);
485        self
486    }
487
488    /// Set the HTTP client pool configuration.
489    ///
490    /// This sets the process-wide default for newly created clients.
491    /// Already-registered domains keep their existing client.
492    pub const fn http_client_config(mut self, config: HttpClientConfig) -> Self {
493        self.http_client_config = Some(config);
494        self
495    }
496
497    /// Build the [`LoadBalancedTransport`].
498    ///
499    /// # Panics
500    ///
501    /// Panics if endpoints is empty or any weight is 0.
502    pub fn build(self) -> LoadBalancedTransport {
503        // Apply global configs if provided.
504        if let Some(tc) = self.throttle_config {
505            set_default_throttle_config(tc);
506        }
507        if let Some(hc) = self.http_client_config {
508            set_default_http_client_config(hc);
509        }
510
511        assert!(!self.endpoints.is_empty(), "at least one endpoint required");
512
513        let mut transports = Vec::with_capacity(self.endpoints.len());
514        let mut throttles = Vec::with_capacity(self.endpoints.len());
515        let mut cumulative = Vec::with_capacity(self.endpoints.len());
516        let mut total = 0u64;
517
518        let mut request_caps = Vec::with_capacity(self.endpoints.len());
519        let mut in_flight_limits = Vec::with_capacity(self.endpoints.len());
520        let mut request_rate_limits = Vec::with_capacity(self.endpoints.len());
521        let mut endpoint_ids = Vec::with_capacity(self.endpoints.len());
522        let mut endpoint_flashblocks = Vec::with_capacity(self.endpoints.len());
523        let mut endpoint_index_by_id = HashMap::new();
524        let mut flashblocks_qualifications = HashMap::new();
525        let mut throttles_by_domain = HashMap::new();
526
527        for endpoint in self.endpoints {
528            let EndpointConfig {
529                id,
530                url,
531                weight,
532                max_request_bytes,
533                max_in_flight,
534                max_requests_per_second,
535                flashblocks,
536            } = endpoint;
537            assert!(weight.0 > 0, "endpoint weight must be > 0");
538            assert!(
539                max_request_bytes.is_none_or(|limit| limit > 0),
540                "endpoint max_request_bytes must be > 0 when set"
541            );
542            assert!(
543                max_in_flight.is_none_or(|limit| limit > 0),
544                "endpoint max_in_flight must be > 0 when set"
545            );
546            assert!(
547                max_requests_per_second.is_none_or(|limit| limit > 0),
548                "endpoint max_requests_per_second must be > 0 when set"
549            );
550            assert!(
551                !flashblocks || id.is_some(),
552                "Flashblocks endpoints must have an endpoint id"
553            );
554            if let Some(id) = &id {
555                assert!(
556                    endpoint_index_by_id
557                        .insert(id.clone(), transports.len())
558                        .is_none(),
559                    "duplicate endpoint id {id}"
560                );
561                if flashblocks {
562                    flashblocks_qualifications
563                        .insert(id.clone(), FlashblocksQualification::default());
564                }
565            }
566            total = total
567                .checked_add(u64::from(weight.0))
568                .expect("total endpoint weight exceeds u64::MAX");
569            cumulative.push(total);
570
571            let domain = {
572                let host = url.host_str().unwrap_or("unknown");
573                extract_rate_limit_domain(host).to_owned()
574            };
575
576            let client = shared_client_for_domain(&domain);
577            let throttle = throttles_by_domain
578                .entry(domain.clone())
579                .or_insert_with(|| domain_throttle(&domain));
580            throttles.push(Arc::clone(throttle));
581            transports.push(Http::with_client(client, url));
582            request_caps.push(max_request_bytes);
583            in_flight_limits.push(max_in_flight.map(|limit| Arc::new(Semaphore::new(limit))));
584            request_rate_limits.push(
585                max_requests_per_second.map(|limit| Arc::new(RequestRateLimiter::new(limit))),
586            );
587            endpoint_ids.push(id);
588            endpoint_flashblocks.push(flashblocks);
589        }
590
591        debug!(
592            endpoints = transports.len(),
593            "LoadBalancedTransport created with shared HTTP clients, proactive request-rate admission, and cross-thread throttling"
594        );
595
596        LoadBalancedTransport {
597            endpoints: Arc::new(transports),
598            endpoint_throttles: Arc::new(throttles),
599            endpoint_request_caps: Arc::new(request_caps),
600            endpoint_in_flight: Arc::new(in_flight_limits),
601            endpoint_request_rate_limits: Arc::new(request_rate_limits),
602            endpoint_ids: Arc::new(endpoint_ids),
603            endpoint_flashblocks: Arc::new(endpoint_flashblocks),
604            endpoint_index_by_id: Arc::new(endpoint_index_by_id),
605            flashblocks_qualifications: Arc::new(RwLock::new(flashblocks_qualifications)),
606            cumulative_weights: Arc::new(cumulative),
607            total_weight: total,
608            counter: Arc::new(AtomicU64::new(0)),
609            config: Arc::new(self.config),
610            route_policy: RoutePolicy::Balanced,
611        }
612    }
613}
614
615/// A load-balanced RPC transport that distributes requests across multiple
616/// HTTP endpoints using weighted round-robin with automatic failover.
617///
618/// Implements `tower::Service<RequestPacket>` and is `Clone + Send + Sync`,
619/// so it satisfies alloy's `Transport` trait and can be passed to
620/// `RpcClient::new()` / `ProviderBuilder::connect_client()`.
621///
622/// **Cross-thread throttling**: endpoints on the same domain share a global
623/// [`DomainThrottleState`] so that a 429 from any chain thread causes all
624/// threads targeting that domain to back off.
625///
626/// **HTTP client sharing**: endpoints on the same domain share a single
627/// `reqwest::Client` configured for HTTP/2 multiplexing and connection pooling.
628///
629/// When all endpoints fail with retryable errors (413, 429, 502, 503, 504),
630/// the transport retries the full rotation with exponential backoff.
631#[derive(Clone)]
632#[cfg(feature = "balancer")]
633pub struct LoadBalancedTransport {
634    /// Inner HTTP transports, one per configured endpoint.
635    endpoints: Arc<Vec<Http<reqwest::Client>>>,
636    /// Per-endpoint domain throttle state. Endpoints on the same domain
637    /// share the same `Arc<DomainThrottleState>`.
638    endpoint_throttles: Arc<Vec<Arc<DomainThrottleState>>>,
639    /// Optional maximum serialized request bytes per endpoint.
640    endpoint_request_caps: Arc<Vec<Option<usize>>>,
641    /// Optional per-endpoint concurrency gates.
642    endpoint_in_flight: Arc<Vec<Option<Arc<Semaphore>>>>,
643    /// Optional rolling one-second JSON-RPC admission limits.
644    endpoint_request_rate_limits: Arc<Vec<Option<Arc<RequestRateLimiter>>>>,
645    /// Stable operator identities, when configured.
646    endpoint_ids: Arc<Vec<Option<EndpointId>>>,
647    /// Provider-level Flashblocks declarations.
648    endpoint_flashblocks: Arc<Vec<bool>>,
649    /// Endpoint lookup by stable identity.
650    endpoint_index_by_id: Arc<HashMap<EndpointId, usize>>,
651    /// Runtime qualification is shared across every scoped transport clone.
652    flashblocks_qualifications: Arc<RwLock<HashMap<EndpointId, FlashblocksQualification>>>,
653    /// Precomputed cumulative weight boundaries for O(log n) weighted selection.
654    cumulative_weights: Arc<Vec<u64>>,
655    /// Sum of all endpoint weights.
656    total_weight: u64,
657    /// Monotonically increasing counter for round-robin distribution.
658    counter: Arc<AtomicU64>,
659    /// Balancer retry/failover configuration.
660    config: Arc<BalancerConfig>,
661    /// Ordering policy scoped to this transport clone.
662    route_policy: RoutePolicy,
663}
664
665/// A pinned Flashblocks-capable transport and its provider identity.
666///
667/// The lease shares connection pools, request-rate capacity, and throttle state
668/// with the parent balancer, but its transport never fails over to another
669/// endpoint.
670#[derive(Clone)]
671#[cfg(feature = "balancer")]
672pub struct EndpointLease {
673    endpoint_id: EndpointId,
674    transport: LoadBalancedTransport,
675}
676
677#[cfg(feature = "balancer")]
678impl EndpointLease {
679    /// Provider pinned by this lease.
680    pub const fn endpoint_id(&self) -> &EndpointId {
681        &self.endpoint_id
682    }
683
684    /// Clone the pinned Alloy transport.
685    pub fn transport(&self) -> LoadBalancedTransport {
686        self.transport.clone()
687    }
688}
689
690#[cfg(feature = "balancer")]
691impl fmt::Debug for EndpointLease {
692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693        f.debug_struct("EndpointLease")
694            .field("endpoint_id", &self.endpoint_id)
695            .finish_non_exhaustive()
696    }
697}
698
699#[cfg(feature = "balancer")]
700impl LoadBalancedTransport {
701    /// Create a new load-balanced transport from a list of `(url, weight)` pairs
702    /// with default configuration.
703    ///
704    /// Endpoints on the same domain automatically share:
705    /// - A single `reqwest::Client` (HTTP/2 multiplexing + connection pool)
706    /// - A single [`DomainThrottleState`] (cross-thread rate-limit coordination)
707    ///
708    /// For custom configuration, use [`LoadBalancedTransport::builder`].
709    ///
710    /// # Panics
711    ///
712    /// Panics if `endpoints` is empty or any weight is 0.
713    ///
714    /// # Errors
715    ///
716    /// The transport retries automatically on 413, 429, 502, 503, 504, connection
717    /// failures, and timeouts. Non-retryable errors (400, 401, JSON-RPC errors)
718    /// are returned immediately. After `max_retry_rounds` full rotations through
719    /// all endpoints, the last retryable error is returned to the caller.
720    pub fn new(endpoints: Vec<(Url, Weight)>) -> Self {
721        Self::builder(endpoints).build()
722    }
723
724    /// Create a builder for a load-balanced transport.
725    ///
726    /// # Example
727    ///
728    /// ```
729    /// use alloy_transport_balancer::{LoadBalancedTransport, Weight, BalancerConfig};
730    /// use reqwest::Url;
731    ///
732    /// let transport = LoadBalancedTransport::builder(vec![
733    ///     (Url::parse("http://localhost:8545").unwrap(), Weight::default()),
734    /// ])
735    /// .config(BalancerConfig { max_retry_rounds: 5, ..Default::default() })
736    /// .build();
737    /// ```
738    pub fn builder(endpoints: Vec<(Url, Weight)>) -> LoadBalancedTransportBuilder {
739        Self::builder_with_endpoints(endpoints.into_iter().map(EndpointConfig::from).collect())
740    }
741
742    /// Create a builder with per-endpoint request, concurrency, and rate capabilities.
743    pub fn builder_with_endpoints(endpoints: Vec<EndpointConfig>) -> LoadBalancedTransportBuilder {
744        LoadBalancedTransportBuilder {
745            endpoints,
746            config: BalancerConfig::default(),
747            throttle_config: None,
748            http_client_config: None,
749        }
750    }
751
752    /// Return the routing policy scoped to this transport handle.
753    pub const fn route_policy(&self) -> &RoutePolicy {
754        &self.route_policy
755    }
756
757    /// Whether the named endpoint is configured for Flashblocks.
758    pub fn endpoint_supports_flashblocks(&self, id: &EndpointId) -> bool {
759        self.endpoint_index_by_id
760            .get(id)
761            .is_some_and(|&index| self.endpoint_flashblocks[index])
762    }
763
764    /// Snapshot every configured proactive request-rate limit.
765    ///
766    /// Endpoint URLs are deliberately excluded; use stable endpoint IDs for
767    /// operator-facing metrics and diagnostics.
768    pub fn endpoint_request_rate_snapshot(&self) -> Vec<EndpointRequestRateSnapshot> {
769        self.endpoint_request_rate_limits
770            .iter()
771            .enumerate()
772            .filter_map(|(index, limiter)| {
773                limiter.as_ref().map(|limiter| {
774                    let snapshot = limiter.snapshot();
775                    EndpointRequestRateSnapshot {
776                        endpoint_id: self.endpoint_ids[index].clone(),
777                        max_requests_per_second: snapshot.limit,
778                        used_in_rolling_window: snapshot.used,
779                        remaining_in_rolling_window: snapshot.remaining,
780                        total_admitted_rpc_calls: snapshot.total_admitted_rpc_calls,
781                        total_capacity_rollovers: snapshot.total_capacity_rollovers,
782                        total_capacity_waits: snapshot.total_capacity_waits,
783                    }
784                })
785            })
786            .collect()
787    }
788
789    /// Clone this transport with source-first routing for canonical follow-up
790    /// reads. Retryable failures may still use another configured endpoint.
791    pub fn prefer_endpoint(&self, id: impl Into<EndpointId>) -> Result<Self, EndpointRouteError> {
792        self.with_route_policy(RoutePolicy::Prefer(id.into()))
793    }
794
795    /// Create a pinned lease for Flashblocks and preconfirmed-state reads.
796    ///
797    /// The endpoint must exist and be explicitly marked with
798    /// [`EndpointConfig::with_flashblocks`]. The returned transport retries the
799    /// same endpoint but never crosses into another provider's pending cache.
800    pub fn flashblocks_lease(
801        &self,
802        id: impl Into<EndpointId>,
803    ) -> Result<EndpointLease, EndpointRouteError> {
804        let id = id.into();
805        if !self.endpoint_index_by_id.contains_key(&id) {
806            return Err(EndpointRouteError::UnknownEndpoint(id));
807        }
808        if !self.endpoint_supports_flashblocks(&id) {
809            return Err(EndpointRouteError::FlashblocksUnsupported(id));
810        }
811        let transport = self.with_route_policy(RoutePolicy::Require(id.clone()))?;
812        Ok(EndpointLease {
813            endpoint_id: id,
814            transport,
815        })
816    }
817
818    /// Record one live Flashblocks capability/liveness probe result.
819    ///
820    /// Qualification is process-local and shared by every scoped clone. A
821    /// later success replaces an ineligible result, allowing transient provider
822    /// failures to recover without rebuilding transports.
823    pub fn record_flashblocks_probe(
824        &self,
825        id: impl Into<EndpointId>,
826        outcome: FlashblocksProbeOutcome,
827    ) -> Result<(), EndpointRouteError> {
828        let id = id.into();
829        if !self.endpoint_index_by_id.contains_key(&id) {
830            return Err(EndpointRouteError::UnknownEndpoint(id));
831        }
832        if !self.endpoint_supports_flashblocks(&id) {
833            return Err(EndpointRouteError::FlashblocksUnsupported(id));
834        }
835        let mut qualifications = self
836            .flashblocks_qualifications
837            .write()
838            .expect("Flashblocks qualification lock poisoned");
839        let qualification = qualifications
840            .get_mut(&id)
841            .expect("declared Flashblocks endpoint missing qualification state");
842        qualification.attempts = qualification.attempts.saturating_add(1);
843        qualification.state = match outcome {
844            FlashblocksProbeOutcome::Qualified {
845                chain_id,
846                mut capabilities,
847            } => {
848                qualification.successes = qualification.successes.saturating_add(1);
849                capabilities.sort_unstable();
850                capabilities.dedup();
851                FlashblocksQualificationState::Qualified {
852                    chain_id,
853                    capabilities,
854                }
855            }
856            FlashblocksProbeOutcome::Ineligible { reason } => {
857                qualification.failures = qualification.failures.saturating_add(1);
858                FlashblocksQualificationState::Ineligible { reason }
859            }
860        };
861        Ok(())
862    }
863
864    /// Return the latest process-local qualification for a declared endpoint.
865    pub fn flashblocks_qualification(
866        &self,
867        id: &EndpointId,
868    ) -> Result<FlashblocksQualificationSnapshot, EndpointRouteError> {
869        if !self.endpoint_index_by_id.contains_key(id) {
870            return Err(EndpointRouteError::UnknownEndpoint(id.clone()));
871        }
872        if !self.endpoint_supports_flashblocks(id) {
873            return Err(EndpointRouteError::FlashblocksUnsupported(id.clone()));
874        }
875        Ok(self
876            .flashblocks_qualifications
877            .read()
878            .expect("Flashblocks qualification lock poisoned")
879            .get(id)
880            .expect("declared Flashblocks endpoint missing qualification state")
881            .snapshot())
882    }
883
884    /// Create a provider-pinned lease only after the endpoint's latest runtime
885    /// preflight has qualified it.
886    pub fn qualified_flashblocks_lease(
887        &self,
888        id: impl Into<EndpointId>,
889    ) -> Result<EndpointLease, EndpointRouteError> {
890        let id = id.into();
891        let qualification = self.flashblocks_qualification(&id)?;
892        if !matches!(
893            qualification.state,
894            FlashblocksQualificationState::Qualified { .. }
895        ) {
896            return Err(EndpointRouteError::FlashblocksUnqualified(id));
897        }
898        self.flashblocks_lease(id)
899    }
900
901    fn with_route_policy(&self, route_policy: RoutePolicy) -> Result<Self, EndpointRouteError> {
902        let id = match &route_policy {
903            RoutePolicy::Balanced => None,
904            RoutePolicy::Prefer(id) | RoutePolicy::Require(id) => Some(id),
905        };
906        if let Some(id) = id
907            && !self.endpoint_index_by_id.contains_key(id)
908        {
909            return Err(EndpointRouteError::UnknownEndpoint(id.clone()));
910        }
911        let mut routed = self.clone();
912        routed.route_policy = route_policy;
913        Ok(routed)
914    }
915
916    /// Select an endpoint index using weighted round-robin.
917    ///
918    /// Uses an atomic counter mapped into cumulative weight buckets via
919    /// binary search for lock-free O(log n) selection.
920    fn select_endpoint(&self) -> usize {
921        let tick = self.counter.fetch_add(1, Ordering::Relaxed);
922        let bucket = tick % self.total_weight;
923        self.cumulative_weights.partition_point(|&w| w <= bucket)
924    }
925
926    fn endpoint_accepts_request(&self, index: usize, request_bytes: usize) -> bool {
927        self.endpoint_request_caps[index].is_none_or(|limit| request_bytes <= limit)
928    }
929
930    fn log_request_rate_denial(
931        &self,
932        index: usize,
933        request_units: u32,
934        reservation: RequestRateReservation,
935    ) {
936        let endpoint_id = self.endpoint_ids[index]
937            .as_ref()
938            .map_or("<anonymous>", EndpointId::as_str);
939        match reservation {
940            RequestRateReservation::Admitted => {}
941            RequestRateReservation::RetryAfter(retry_after) => debug!(
942                endpoint_id,
943                request_units,
944                retry_after_ms = retry_after.as_millis() as u64,
945                "RPC endpoint request-rate capacity unavailable"
946            ),
947            RequestRateReservation::PacketTooLarge => debug!(
948                endpoint_id,
949                request_units, "JSON-RPC packet exceeds endpoint request-rate capacity"
950            ),
951        }
952    }
953
954    fn candidate_indices(&self, request_bytes: usize) -> Vec<usize> {
955        let mut candidates = Vec::with_capacity(self.endpoints.len());
956        match &self.route_policy {
957            RoutePolicy::Balanced => {
958                let start = self.select_endpoint();
959                for attempt in 0..self.endpoints.len() {
960                    let index = (start + attempt) % self.endpoints.len();
961                    if self.endpoint_accepts_request(index, request_bytes) {
962                        candidates.push(index);
963                    }
964                }
965            }
966            RoutePolicy::Prefer(id) => {
967                let preferred = self.endpoint_index_by_id[id];
968                if self.endpoint_accepts_request(preferred, request_bytes) {
969                    candidates.push(preferred);
970                }
971                let start = self.select_endpoint();
972                for attempt in 0..self.endpoints.len() {
973                    let index = (start + attempt) % self.endpoints.len();
974                    if index != preferred && self.endpoint_accepts_request(index, request_bytes) {
975                        candidates.push(index);
976                    }
977                }
978            }
979            RoutePolicy::Require(id) => {
980                let required = self.endpoint_index_by_id[id];
981                if self.endpoint_accepts_request(required, request_bytes) {
982                    candidates.push(required);
983                }
984            }
985        }
986        candidates
987    }
988
989    /// Returns `true` if the error warrants failover to the next provider.
990    ///
991    /// Retryable: 429 (rate limit), 502/503/504 (gateway errors),
992    /// connection failures, timeouts, and backend-gone.
993    fn is_retryable(err: &TransportError) -> bool {
994        if let RpcError::Transport(kind) = err {
995            match kind {
996                TransportErrorKind::HttpError(http_err) => {
997                    matches!(http_err.status, 413 | 429 | 502 | 503 | 504)
998                }
999                TransportErrorKind::BackendGone => true,
1000                TransportErrorKind::Custom(e) => {
1001                    let msg = e.to_string();
1002                    msg.contains("timed out")
1003                        || msg.contains("connection")
1004                        || msg.contains("429 Too Many Requests")
1005                }
1006                _ => false,
1007            }
1008        } else {
1009            false
1010        }
1011    }
1012
1013    /// Returns `true` if the error is specifically a 429 rate-limit response.
1014    fn is_rate_limit(err: &TransportError) -> bool {
1015        if let RpcError::Transport(kind) = err {
1016            match kind {
1017                TransportErrorKind::HttpError(http_err) => http_err.status == 429,
1018                TransportErrorKind::Custom(e) => e.to_string().contains("429 Too Many Requests"),
1019                _ => false,
1020            }
1021        } else {
1022            false
1023        }
1024    }
1025}
1026
1027#[cfg(feature = "balancer")]
1028impl Service<RequestPacket> for LoadBalancedTransport {
1029    type Response = ResponsePacket;
1030    type Error = TransportError;
1031    type Future = TransportFut<'static>;
1032
1033    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1034        Poll::Ready(Ok(()))
1035    }
1036
1037    fn call(&mut self, req: RequestPacket) -> Self::Future {
1038        let this = self.clone();
1039        Box::pin(async move {
1040            let request_bytes = serde_json::to_vec(&req)
1041                .map(|serialized| serialized.len())
1042                .unwrap_or(usize::MAX);
1043            let request_units = u32::try_from(req.len().max(1)).unwrap_or(u32::MAX);
1044            let mut candidates = this.candidate_indices(request_bytes);
1045            if candidates.is_empty() {
1046                return Err(TransportErrorKind::custom_str(
1047                    "JSON-RPC request exceeds every endpoint allowed by the routing policy",
1048                ));
1049            }
1050            let config = &this.config;
1051            let mut backoff = config.initial_backoff;
1052
1053            for round in 0..=config.max_retry_rounds {
1054                if round > 0 {
1055                    warn!(
1056                        round,
1057                        backoff_ms = backoff.as_millis() as u64,
1058                        "all endpoints failed with retryable errors, backing off before retry"
1059                    );
1060                    sleep(backoff).await;
1061                    backoff = (backoff * 2).min(config.max_backoff);
1062                    candidates = this.candidate_indices(request_bytes);
1063                }
1064
1065                let mut last_err: Option<TransportError> = None;
1066
1067                loop {
1068                    let mut rate_fallback = RequestRateFallback::default();
1069
1070                    for (attempt, &idx) in candidates.iter().enumerate() {
1071                        if let Some(rate_limit) = &this.endpoint_request_rate_limits[idx] {
1072                            let reservation = rate_limit.check(request_units);
1073                            if rate_fallback.rejects(rate_limit, reservation) {
1074                                this.log_request_rate_denial(idx, request_units, reservation);
1075                                continue;
1076                            }
1077                        }
1078
1079                        // Cross-thread domain throttle: skip heavily-throttled
1080                        // endpoints if non-throttled alternatives remain.
1081                        let throttle = &this.endpoint_throttles[idx];
1082                        let delay = pre_request_delay(throttle);
1083                        let has_unthrottled_eligible_alternative =
1084                            candidates[(attempt + 1)..].iter().any(|&next_idx| {
1085                                pre_request_delay(&this.endpoint_throttles[next_idx])
1086                                    < config.heavy_throttle_threshold
1087                            });
1088                        let is_preferred_source =
1089                            attempt == 0 && matches!(&this.route_policy, RoutePolicy::Prefer(_));
1090                        if !is_preferred_source
1091                            && delay >= config.heavy_throttle_threshold
1092                            && has_unthrottled_eligible_alternative
1093                        {
1094                            record_skip(throttle);
1095                            continue;
1096                        }
1097
1098                        // Apply domain-level backoff delay before sending.
1099                        if !delay.is_zero() {
1100                            sleep(delay).await;
1101                        }
1102
1103                        let permit = match &this.endpoint_in_flight[idx] {
1104                            Some(limit) => Some(
1105                                Arc::clone(limit)
1106                                    .acquire_owned()
1107                                    .await
1108                                    .map_err(|_| TransportErrorKind::backend_gone())?,
1109                            ),
1110                            None => None,
1111                        };
1112                        if let Some(rate_limit) = &this.endpoint_request_rate_limits[idx] {
1113                            let reservation = rate_limit.try_reserve(request_units);
1114                            if rate_fallback.rejects(rate_limit, reservation) {
1115                                this.log_request_rate_denial(idx, request_units, reservation);
1116                                drop(permit);
1117                                continue;
1118                            }
1119                        }
1120                        let mut transport = this.endpoints[idx].clone();
1121                        rate_fallback.record_rollovers();
1122
1123                        match transport.call(req.clone()).await {
1124                            Ok(resp) => {
1125                                record_success(throttle);
1126                                return Ok(resp);
1127                            }
1128                            Err(err) => {
1129                                let retryable = Self::is_retryable(&err);
1130                                if !retryable {
1131                                    return Err(err);
1132                                }
1133
1134                                let rate_limited = Self::is_rate_limit(&err);
1135
1136                                // Update cross-thread throttle state on 429.
1137                                if rate_limited {
1138                                    record_rate_limit(throttle);
1139                                }
1140
1141                                warn!(
1142                                    endpoint_id = this.endpoint_ids[idx]
1143                                        .as_ref()
1144                                        .map_or("<anonymous>", EndpointId::as_str),
1145                                    endpoint_attempt = attempt + 1,
1146                                    round,
1147                                    rate_limited,
1148                                    "RPC request failed, failing over: {err}"
1149                                );
1150
1151                                last_err = Some(err);
1152
1153                                if rate_limited && attempt + 1 < candidates.len() {
1154                                    sleep(config.rate_limit_failover_delay).await;
1155                                }
1156                            }
1157                        }
1158                    }
1159
1160                    if last_err.is_none() {
1161                        if let Some((retry_after, rate_limit)) = rate_fallback.earliest_capacity() {
1162                            rate_limit.record_wait();
1163                            debug!(
1164                                request_units,
1165                                retry_after_ms = retry_after.as_millis() as u64,
1166                                "all eligible RPC endpoints lack request-rate capacity; waiting"
1167                            );
1168                            sleep(retry_after).await;
1169                            continue;
1170                        }
1171                        if rate_fallback.packets_too_large() == candidates.len() {
1172                            return Err(TransportErrorKind::custom_str(
1173                                "JSON-RPC packet exceeds every endpoint request-rate capacity allowed by the routing policy",
1174                            ));
1175                        }
1176                    }
1177                    break;
1178                }
1179
1180                // All endpoints failed this round. On the final round, return the error.
1181                if round == config.max_retry_rounds {
1182                    return Err(last_err.expect("at least one endpoint was tried"));
1183                }
1184            }
1185
1186            unreachable!("retry loop always returns")
1187        })
1188    }
1189}
1190
1191#[cfg(feature = "balancer")]
1192impl std::fmt::Debug for LoadBalancedTransport {
1193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1194        f.debug_struct("LoadBalancedTransport")
1195            .field("endpoints", &self.endpoints.len())
1196            .field("total_weight", &self.total_weight)
1197            .field("route_policy", &self.route_policy)
1198            .finish()
1199    }
1200}
1201
1202#[cfg(all(test, feature = "balancer"))]
1203mod tests {
1204    use super::*;
1205    use alloy_json_rpc::Id;
1206    use std::{
1207        io::{Read, Write},
1208        net::TcpListener,
1209        sync::mpsc,
1210        thread,
1211    };
1212
1213    fn one_shot_http_server(
1214        status: &'static str,
1215        body: &'static str,
1216    ) -> (Url, thread::JoinHandle<()>) {
1217        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1218        let address = listener.local_addr().unwrap();
1219        let handle = thread::spawn(move || {
1220            let (mut stream, _) = listener.accept().unwrap();
1221            let mut request = [0_u8; 8 * 1024];
1222            let _ = stream.read(&mut request).unwrap();
1223            let response = format!(
1224                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1225                body.len()
1226            );
1227            stream.write_all(response.as_bytes()).unwrap();
1228        });
1229        (Url::parse(&format!("http://{address}")).unwrap(), handle)
1230    }
1231
1232    fn fixed_http_server(bodies: Vec<&'static str>) -> (Url, thread::JoinHandle<()>) {
1233        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1234        let address = listener.local_addr().unwrap();
1235        let handle = thread::spawn(move || {
1236            for body in bodies {
1237                let (mut stream, _) = listener.accept().unwrap();
1238                let mut request = [0_u8; 8 * 1024];
1239                let _ = stream.read(&mut request).unwrap();
1240                let response = format!(
1241                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1242                    body.len()
1243                );
1244                stream.write_all(response.as_bytes()).unwrap();
1245            }
1246        });
1247        (Url::parse(&format!("http://{address}")).unwrap(), handle)
1248    }
1249
1250    fn delayed_http_server(
1251        delay: Duration,
1252        body: &'static str,
1253    ) -> (Url, mpsc::Receiver<()>, thread::JoinHandle<()>) {
1254        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1255        let address = listener.local_addr().unwrap();
1256        let (accepted_tx, accepted_rx) = mpsc::channel();
1257        let handle = thread::spawn(move || {
1258            let (mut stream, _) = listener.accept().unwrap();
1259            let mut request = [0_u8; 8 * 1024];
1260            let _ = stream.read(&mut request).unwrap();
1261            accepted_tx.send(()).unwrap();
1262            thread::sleep(delay);
1263            let response = format!(
1264                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1265                body.len()
1266            );
1267            stream.write_all(response.as_bytes()).unwrap();
1268        });
1269        (
1270            Url::parse(&format!("http://{address}")).unwrap(),
1271            accepted_rx,
1272            handle,
1273        )
1274    }
1275
1276    fn test_transport(weights: &[u32]) -> LoadBalancedTransport {
1277        let endpoints: Vec<(Url, Weight)> = weights
1278            .iter()
1279            .enumerate()
1280            .map(|(i, &w)| {
1281                (
1282                    Url::parse(&format!("http://provider-{i}.example.com")).unwrap(),
1283                    Weight(w),
1284                )
1285            })
1286            .collect();
1287        LoadBalancedTransport::new(endpoints)
1288    }
1289
1290    fn identified_transport() -> LoadBalancedTransport {
1291        LoadBalancedTransport::builder_with_endpoints(vec![
1292            EndpointConfig::new(Url::parse("http://a.example.com").unwrap(), Weight(1))
1293                .with_id("a"),
1294            EndpointConfig::new(Url::parse("http://b.example.com").unwrap(), Weight(1))
1295                .with_id("b")
1296                .with_flashblocks(true),
1297            EndpointConfig::new(Url::parse("http://c.example.com").unwrap(), Weight(1))
1298                .with_id("c"),
1299        ])
1300        .build()
1301    }
1302
1303    #[test]
1304    fn builder_supports_total_weights_above_u32_max() {
1305        let transport = test_transport(&[u32::MAX, u32::MAX]);
1306
1307        assert_eq!(transport.endpoints.len(), 2);
1308        assert_eq!(transport.total_weight as u128, u128::from(u32::MAX) * 2);
1309    }
1310
1311    #[test]
1312    fn weighted_distribution() {
1313        let transport = test_transport(&[10, 5, 2]);
1314        let total = 10 + 5 + 2;
1315        let iterations = total as usize * 1000;
1316        let mut counts = [0usize; 3];
1317
1318        for _ in 0..iterations {
1319            let idx = transport.select_endpoint();
1320            counts[idx] += 1;
1321        }
1322
1323        // With perfect round-robin, distribution should be exact over full cycles.
1324        // 17000 requests / 17 total_weight = 1000 full cycles.
1325        assert_eq!(counts[0], 10_000, "provider 0 (weight=10)");
1326        assert_eq!(counts[1], 5_000, "provider 1 (weight=5)");
1327        assert_eq!(counts[2], 2_000, "provider 2 (weight=2)");
1328    }
1329
1330    #[test]
1331    fn single_endpoint() {
1332        let transport = test_transport(&[5]);
1333        for _ in 0..100 {
1334            assert_eq!(transport.select_endpoint(), 0);
1335        }
1336    }
1337
1338    #[test]
1339    fn equal_weights() {
1340        let transport = test_transport(&[1, 1, 1]);
1341        let mut counts = [0usize; 3];
1342        for _ in 0..300 {
1343            counts[transport.select_endpoint()] += 1;
1344        }
1345        assert_eq!(counts, [100, 100, 100]);
1346    }
1347
1348    #[test]
1349    #[should_panic(expected = "at least one endpoint required")]
1350    fn empty_endpoints_panics() {
1351        LoadBalancedTransport::new(vec![]);
1352    }
1353
1354    #[test]
1355    #[should_panic(expected = "endpoint weight must be > 0")]
1356    fn zero_weight_panics() {
1357        test_transport(&[10, 0, 5]);
1358    }
1359
1360    #[test]
1361    fn weight_default_is_100() {
1362        assert_eq!(Weight::default(), Weight(100));
1363    }
1364
1365    #[test]
1366    fn weight_from_u32() {
1367        assert_eq!(Weight::from(42), Weight(42));
1368    }
1369
1370    #[test]
1371    fn balancer_config_defaults_are_sane() {
1372        let config = BalancerConfig::default();
1373        assert!(config.max_retry_rounds >= 1);
1374        assert!(config.initial_backoff <= config.max_backoff);
1375        assert!(config.rate_limit_failover_delay < config.initial_backoff);
1376    }
1377
1378    #[test]
1379    fn builder_with_defaults() {
1380        let transport = LoadBalancedTransport::builder(vec![(
1381            Url::parse("http://example.com").unwrap(),
1382            Weight::default(),
1383        )])
1384        .build();
1385        assert_eq!(transport.total_weight, 100);
1386    }
1387
1388    #[test]
1389    fn builder_with_custom_config() {
1390        let config = BalancerConfig {
1391            max_retry_rounds: 5,
1392            ..Default::default()
1393        };
1394        let transport = LoadBalancedTransport::builder(vec![(
1395            Url::parse("http://example.com").unwrap(),
1396            Weight::default(),
1397        )])
1398        .config(config)
1399        .build();
1400        assert_eq!(transport.config.max_retry_rounds, 5);
1401    }
1402
1403    #[test]
1404    fn preferred_route_orders_source_first_without_changing_parent() {
1405        let transport = identified_transport();
1406        transport.counter.store(2, Ordering::Relaxed);
1407
1408        let preferred = transport.prefer_endpoint("b").unwrap();
1409        let candidates = preferred.candidate_indices(1);
1410
1411        assert_eq!(candidates.first(), Some(&1));
1412        assert_eq!(candidates.len(), 3);
1413        assert!(matches!(transport.route_policy(), RoutePolicy::Balanced));
1414        assert!(matches!(
1415            preferred.route_policy(),
1416            RoutePolicy::Prefer(id) if id.as_str() == "b"
1417        ));
1418    }
1419
1420    #[test]
1421    fn flashblocks_lease_is_pinned_to_one_declared_endpoint() {
1422        let transport = identified_transport();
1423        let lease = transport.flashblocks_lease("b").unwrap();
1424        let pinned = lease.transport();
1425
1426        assert_eq!(lease.endpoint_id().as_str(), "b");
1427        assert_eq!(pinned.candidate_indices(1), vec![1]);
1428        assert!(matches!(
1429            pinned.route_policy(),
1430            RoutePolicy::Require(id) if id.as_str() == "b"
1431        ));
1432    }
1433
1434    #[test]
1435    fn flashblocks_lease_rejects_undeclared_endpoint_support() {
1436        let transport = identified_transport();
1437
1438        assert_eq!(
1439            transport.flashblocks_lease("a").unwrap_err(),
1440            EndpointRouteError::FlashblocksUnsupported(EndpointId::from("a"))
1441        );
1442        assert_eq!(
1443            transport.flashblocks_lease("missing").unwrap_err(),
1444            EndpointRouteError::UnknownEndpoint(EndpointId::from("missing"))
1445        );
1446    }
1447
1448    #[test]
1449    fn flashblocks_qualification_is_shared_observable_and_recoverable() {
1450        let transport = identified_transport();
1451        let id = EndpointId::from("b");
1452
1453        assert_eq!(
1454            transport
1455                .qualified_flashblocks_lease(id.clone())
1456                .unwrap_err(),
1457            EndpointRouteError::FlashblocksUnqualified(id.clone())
1458        );
1459        assert_eq!(
1460            transport.flashblocks_qualification(&id).unwrap(),
1461            FlashblocksQualificationSnapshot {
1462                attempts: 0,
1463                successes: 0,
1464                failures: 0,
1465                state: FlashblocksQualificationState::Unprobed,
1466            }
1467        );
1468
1469        let clone = transport.clone();
1470        clone
1471            .record_flashblocks_probe(
1472                id.clone(),
1473                FlashblocksProbeOutcome::Ineligible {
1474                    reason: "pendingLogs subscription did not become live".to_owned(),
1475                },
1476            )
1477            .unwrap();
1478        assert_eq!(
1479            transport
1480                .qualified_flashblocks_lease(id.clone())
1481                .unwrap_err(),
1482            EndpointRouteError::FlashblocksUnqualified(id.clone())
1483        );
1484
1485        transport
1486            .record_flashblocks_probe(
1487                id.clone(),
1488                FlashblocksProbeOutcome::Qualified {
1489                    chain_id: 8_453,
1490                    capabilities: vec!["flashblocks".to_owned()],
1491                },
1492            )
1493            .unwrap();
1494        let snapshot = clone.flashblocks_qualification(&id).unwrap();
1495        assert_eq!(snapshot.attempts, 2);
1496        assert_eq!(snapshot.successes, 1);
1497        assert_eq!(snapshot.failures, 1);
1498        assert!(matches!(
1499            snapshot.state,
1500            FlashblocksQualificationState::Qualified {
1501                chain_id: 8_453,
1502                ..
1503            }
1504        ));
1505        assert_eq!(
1506            transport
1507                .qualified_flashblocks_lease(id)
1508                .unwrap()
1509                .endpoint_id()
1510                .as_str(),
1511            "b"
1512        );
1513    }
1514
1515    #[test]
1516    #[should_panic(expected = "Flashblocks endpoints must have an endpoint id")]
1517    fn flashblocks_endpoint_requires_identity() {
1518        LoadBalancedTransport::builder_with_endpoints(vec![
1519            EndpointConfig::new(Url::parse("http://a.example.com").unwrap(), Weight(1))
1520                .with_flashblocks(true),
1521        ])
1522        .build();
1523    }
1524
1525    #[test]
1526    #[should_panic(expected = "duplicate endpoint id a")]
1527    fn duplicate_endpoint_identity_is_rejected() {
1528        LoadBalancedTransport::builder_with_endpoints(vec![
1529            EndpointConfig::new(Url::parse("http://a.example.com").unwrap(), Weight(1))
1530                .with_id("a"),
1531            EndpointConfig::new(Url::parse("http://b.example.com").unwrap(), Weight(1))
1532                .with_id("a"),
1533        ])
1534        .build();
1535    }
1536
1537    #[test]
1538    #[should_panic(expected = "endpoint max_requests_per_second must be > 0 when set")]
1539    fn zero_endpoint_request_rate_limit_is_rejected() {
1540        LoadBalancedTransport::builder_with_endpoints(vec![
1541            EndpointConfig::new(Url::parse("http://a.example.com").unwrap(), Weight(1))
1542                .with_max_requests_per_second(0),
1543        ])
1544        .build();
1545    }
1546
1547    #[test]
1548    fn large_endpoint_request_rate_limit_does_not_preallocate_the_window() {
1549        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1550            EndpointConfig::new(Url::parse("http://a.example.com").unwrap(), Weight(1))
1551                .with_id("primary")
1552                .with_max_requests_per_second(u32::MAX),
1553        ])
1554        .build();
1555
1556        assert_eq!(
1557            transport.endpoint_request_rate_snapshot()[0].max_requests_per_second,
1558            u32::MAX
1559        );
1560    }
1561
1562    #[test]
1563    fn retryable_429() {
1564        let err = TransportErrorKind::http_error(429, "rate limited".into());
1565        assert!(LoadBalancedTransport::is_retryable(&err));
1566    }
1567
1568    #[test]
1569    fn retryable_502() {
1570        let err = TransportErrorKind::http_error(502, "bad gateway".into());
1571        assert!(LoadBalancedTransport::is_retryable(&err));
1572    }
1573
1574    #[test]
1575    fn retryable_503() {
1576        let err = TransportErrorKind::http_error(503, "unavailable".into());
1577        assert!(LoadBalancedTransport::is_retryable(&err));
1578    }
1579
1580    #[test]
1581    fn retryable_504() {
1582        let err = TransportErrorKind::http_error(504, "gateway timeout".into());
1583        assert!(LoadBalancedTransport::is_retryable(&err));
1584    }
1585
1586    #[test]
1587    fn not_retryable_400() {
1588        let err = TransportErrorKind::http_error(400, "bad request".into());
1589        assert!(!LoadBalancedTransport::is_retryable(&err));
1590    }
1591
1592    #[test]
1593    fn retryable_413_can_fail_over_to_a_larger_endpoint() {
1594        let err = TransportErrorKind::http_error(413, "payload too large".into());
1595        assert!(LoadBalancedTransport::is_retryable(&err));
1596    }
1597
1598    #[test]
1599    fn endpoint_request_caps_gate_eligibility() {
1600        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1601            EndpointConfig::new(Url::parse("http://small.example.com").unwrap(), Weight(25))
1602                .with_max_request_bytes(1_000),
1603            EndpointConfig::new(Url::parse("http://large.example.com").unwrap(), Weight(100))
1604                .with_max_request_bytes(5_000),
1605        ])
1606        .build();
1607
1608        assert!(!transport.endpoint_accepts_request(0, 2_000));
1609        assert!(transport.endpoint_accepts_request(1, 2_000));
1610    }
1611
1612    #[tokio::test]
1613    async fn heavily_throttled_final_eligible_endpoint_is_still_attempted() {
1614        let mut transport = LoadBalancedTransport::builder_with_endpoints(vec![
1615            EndpointConfig::new(Url::parse("http://127.0.0.1:1").unwrap(), Weight(1)),
1616            EndpointConfig::new(Url::parse("http://127.0.0.1:2").unwrap(), Weight(1))
1617                .with_max_request_bytes(1),
1618        ])
1619        .config(BalancerConfig {
1620            max_retry_rounds: 0,
1621            heavy_throttle_threshold: Duration::ZERO,
1622            ..Default::default()
1623        })
1624        .build();
1625        let request = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(1), ());
1626
1627        let result = transport
1628            .call(RequestPacket::Single(request.serialize().unwrap()))
1629            .await;
1630
1631        assert!(result.is_err());
1632    }
1633
1634    #[tokio::test]
1635    async fn payload_too_large_response_fails_over_to_next_endpoint() {
1636        let (small_url, small_server) = one_shot_http_server("413 Payload Too Large", "");
1637        let (large_url, large_server) =
1638            one_shot_http_server("200 OK", r#"{"jsonrpc":"2.0","id":2,"result":"0x1"}"#);
1639        let mut transport =
1640            LoadBalancedTransport::builder(vec![(small_url, Weight(1)), (large_url, Weight(1))])
1641                .config(BalancerConfig {
1642                    max_retry_rounds: 0,
1643                    ..Default::default()
1644                })
1645                .build();
1646        let request = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(2), ());
1647
1648        let response = transport
1649            .call(RequestPacket::Single(request.serialize().unwrap()))
1650            .await
1651            .unwrap();
1652
1653        assert!(matches!(response, ResponsePacket::Single(_)));
1654        small_server.join().unwrap();
1655        large_server.join().unwrap();
1656    }
1657
1658    #[tokio::test]
1659    async fn preferred_endpoint_at_request_rate_limit_rolls_to_fallback() {
1660        let (primary_url, primary_server) =
1661            one_shot_http_server("200 OK", r#"{"jsonrpc":"2.0","id":3,"result":"primary"}"#);
1662        let (fallback_url, fallback_server) =
1663            one_shot_http_server("200 OK", r#"{"jsonrpc":"2.0","id":4,"result":"fallback"}"#);
1664        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1665            EndpointConfig::new(primary_url, Weight(1))
1666                .with_id("primary")
1667                .with_max_requests_per_second(1),
1668            EndpointConfig::new(fallback_url, Weight(1)).with_id("fallback"),
1669        ])
1670        .config(BalancerConfig {
1671            max_retry_rounds: 0,
1672            ..Default::default()
1673        })
1674        .build();
1675        let mut preferred = transport.prefer_endpoint("primary").unwrap();
1676        let first = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(3), ());
1677        let second = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(4), ());
1678
1679        let first_response = preferred
1680            .call(RequestPacket::Single(first.serialize().unwrap()))
1681            .await
1682            .unwrap();
1683        let second_response = preferred
1684            .call(RequestPacket::Single(second.serialize().unwrap()))
1685            .await
1686            .unwrap();
1687
1688        assert!(matches!(first_response, ResponsePacket::Single(_)));
1689        assert!(matches!(second_response, ResponsePacket::Single(_)));
1690        primary_server.join().unwrap();
1691        fallback_server.join().unwrap();
1692    }
1693
1694    #[tokio::test]
1695    async fn endpoint_request_rate_snapshot_reports_admitted_capacity() {
1696        let (url, server) =
1697            one_shot_http_server("200 OK", r#"{"jsonrpc":"2.0","id":5,"result":"0x1"}"#);
1698        let mut transport = LoadBalancedTransport::builder_with_endpoints(vec![
1699            EndpointConfig::new(url, Weight(1))
1700                .with_id("quicknode")
1701                .with_max_requests_per_second(2),
1702        ])
1703        .build();
1704        let request = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(5), ());
1705
1706        transport
1707            .call(RequestPacket::Single(request.serialize().unwrap()))
1708            .await
1709            .unwrap();
1710
1711        assert_eq!(
1712            transport.endpoint_request_rate_snapshot(),
1713            vec![EndpointRequestRateSnapshot {
1714                endpoint_id: Some(EndpointId::from("quicknode")),
1715                max_requests_per_second: 2,
1716                used_in_rolling_window: 1,
1717                remaining_in_rolling_window: 1,
1718                total_admitted_rpc_calls: 1,
1719                total_capacity_rollovers: 0,
1720                total_capacity_waits: 0,
1721            }]
1722        );
1723        server.join().unwrap();
1724    }
1725
1726    #[tokio::test]
1727    async fn sole_rate_limited_endpoint_waits_for_capacity_without_rollover() {
1728        let (url, server) = fixed_http_server(vec![
1729            r#"{"jsonrpc":"2.0","id":6,"result":"0x1"}"#,
1730            r#"{"jsonrpc":"2.0","id":7,"result":"0x2"}"#,
1731        ]);
1732        let mut transport = LoadBalancedTransport::builder_with_endpoints(vec![
1733            EndpointConfig::new(url, Weight(1))
1734                .with_id("quicknode")
1735                .with_max_requests_per_second(1),
1736        ])
1737        .build();
1738        let first = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(6), ());
1739        let second = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(7), ());
1740        let first_started_at = tokio::time::Instant::now();
1741
1742        transport
1743            .call(RequestPacket::Single(first.serialize().unwrap()))
1744            .await
1745            .unwrap();
1746        transport
1747            .call(RequestPacket::Single(second.serialize().unwrap()))
1748            .await
1749            .unwrap();
1750
1751        assert!(first_started_at.elapsed() >= Duration::from_secs(1));
1752        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1753        assert_eq!(snapshot.total_admitted_rpc_calls, 2);
1754        assert_eq!(snapshot.total_capacity_rollovers, 0);
1755        assert_eq!(snapshot.total_capacity_waits, 1);
1756        server.join().unwrap();
1757    }
1758
1759    #[tokio::test]
1760    async fn batch_members_each_consume_request_rate_capacity() {
1761        let primary_url = Url::parse("http://127.0.0.1:1").unwrap();
1762        let (fallback_url, fallback_server) = one_shot_http_server(
1763            "200 OK",
1764            r#"[{"jsonrpc":"2.0","id":8,"result":"0x1"},{"jsonrpc":"2.0","id":9,"result":"0x2"}]"#,
1765        );
1766        let mut transport = LoadBalancedTransport::builder_with_endpoints(vec![
1767            EndpointConfig::new(primary_url, Weight(1))
1768                .with_id("quicknode")
1769                .with_max_requests_per_second(1),
1770            EndpointConfig::new(fallback_url, Weight(1)).with_id("fallback"),
1771        ])
1772        .config(BalancerConfig {
1773            max_retry_rounds: 0,
1774            ..Default::default()
1775        })
1776        .build()
1777        .prefer_endpoint("quicknode")
1778        .unwrap();
1779        let first = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(8), ());
1780        let second = alloy_json_rpc::Request::new("eth_chainId".to_owned(), Id::Number(9), ());
1781
1782        let response = transport
1783            .call(RequestPacket::Batch(vec![
1784                first.serialize().unwrap(),
1785                second.serialize().unwrap(),
1786            ]))
1787            .await
1788            .unwrap();
1789
1790        assert!(matches!(response, ResponsePacket::Batch(_)));
1791        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1792        assert_eq!(snapshot.total_admitted_rpc_calls, 0);
1793        assert_eq!(snapshot.total_capacity_rollovers, 1);
1794        fallback_server.join().unwrap();
1795    }
1796
1797    #[tokio::test]
1798    async fn packet_larger_than_every_rate_capacity_fails_without_network_io() {
1799        let mut transport = LoadBalancedTransport::builder_with_endpoints(vec![
1800            EndpointConfig::new(Url::parse("http://127.0.0.1:1").unwrap(), Weight(1))
1801                .with_id("quicknode")
1802                .with_max_requests_per_second(1),
1803        ])
1804        .build();
1805        let first = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(20), ());
1806        let second = alloy_json_rpc::Request::new("eth_chainId".to_owned(), Id::Number(21), ());
1807
1808        let error = transport
1809            .call(RequestPacket::Batch(vec![
1810                first.serialize().unwrap(),
1811                second.serialize().unwrap(),
1812            ]))
1813            .await
1814            .unwrap_err();
1815
1816        assert!(
1817            error
1818                .to_string()
1819                .contains("exceeds every endpoint request-rate capacity")
1820        );
1821        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1822        assert_eq!(snapshot.total_admitted_rpc_calls, 0);
1823        assert_eq!(snapshot.total_capacity_rollovers, 0);
1824        assert_eq!(snapshot.total_capacity_waits, 0);
1825    }
1826
1827    #[tokio::test]
1828    async fn concurrent_clones_cannot_overshoot_request_rate_limit() {
1829        const PRIMARY_CAPACITY: usize = 5;
1830        const TOTAL_REQUESTS: usize = 20;
1831        let response = r#"{"jsonrpc":"2.0","id":10,"result":"0x1"}"#;
1832        let (primary_url, primary_server) = fixed_http_server(vec![response; PRIMARY_CAPACITY]);
1833        let (fallback_url, fallback_server) =
1834            fixed_http_server(vec![response; TOTAL_REQUESTS - PRIMARY_CAPACITY]);
1835        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1836            EndpointConfig::new(primary_url, Weight(1))
1837                .with_id("quicknode")
1838                .with_max_requests_per_second(PRIMARY_CAPACITY as u32),
1839            EndpointConfig::new(fallback_url, Weight(1)).with_id("fallback"),
1840        ])
1841        .config(BalancerConfig {
1842            max_retry_rounds: 0,
1843            ..Default::default()
1844        })
1845        .build()
1846        .prefer_endpoint("quicknode")
1847        .unwrap();
1848
1849        let tasks = (0..TOTAL_REQUESTS)
1850            .map(|request_id| {
1851                let mut transport = transport.clone();
1852                tokio::spawn(async move {
1853                    let request = alloy_json_rpc::Request::new(
1854                        "eth_blockNumber".to_owned(),
1855                        Id::Number(request_id as u64 + 10),
1856                        (),
1857                    );
1858                    transport
1859                        .call(RequestPacket::Single(request.serialize().unwrap()))
1860                        .await
1861                        .unwrap();
1862                })
1863            })
1864            .collect::<Vec<_>>();
1865        for task in tasks {
1866            task.await.unwrap();
1867        }
1868
1869        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1870        assert_eq!(snapshot.used_in_rolling_window, PRIMARY_CAPACITY as u32);
1871        assert_eq!(snapshot.total_admitted_rpc_calls, PRIMARY_CAPACITY as u64);
1872        assert_eq!(
1873            snapshot.total_capacity_rollovers,
1874            (TOTAL_REQUESTS - PRIMARY_CAPACITY) as u64
1875        );
1876        primary_server.join().unwrap();
1877        fallback_server.join().unwrap();
1878    }
1879
1880    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1881    async fn full_rate_budget_rolls_over_before_waiting_for_in_flight_permit() {
1882        let (primary_url, primary_accepted, primary_server) = delayed_http_server(
1883            Duration::from_millis(500),
1884            r#"{"jsonrpc":"2.0","id":30,"result":"primary"}"#,
1885        );
1886        let (fallback_url, fallback_server) =
1887            one_shot_http_server("200 OK", r#"{"jsonrpc":"2.0","id":31,"result":"fallback"}"#);
1888        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1889            EndpointConfig::new(primary_url, Weight(1))
1890                .with_id("quicknode")
1891                .with_max_in_flight(1)
1892                .with_max_requests_per_second(1),
1893            EndpointConfig::new(fallback_url, Weight(1)).with_id("fallback"),
1894        ])
1895        .config(BalancerConfig {
1896            max_retry_rounds: 0,
1897            ..Default::default()
1898        })
1899        .build()
1900        .prefer_endpoint("quicknode")
1901        .unwrap();
1902        let mut first_transport = transport.clone();
1903        let first = tokio::spawn(async move {
1904            let request =
1905                alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(30), ());
1906            first_transport
1907                .call(RequestPacket::Single(request.serialize().unwrap()))
1908                .await
1909                .unwrap();
1910        });
1911        primary_accepted
1912            .recv_timeout(Duration::from_secs(2))
1913            .unwrap();
1914        let mut second_transport = transport.clone();
1915        let second = alloy_json_rpc::Request::new("eth_chainId".to_owned(), Id::Number(31), ());
1916
1917        tokio::time::timeout(
1918            Duration::from_millis(250),
1919            second_transport.call(RequestPacket::Single(second.serialize().unwrap())),
1920        )
1921        .await
1922        .expect("fallback should not wait for the primary in-flight permit")
1923        .unwrap();
1924
1925        first.await.unwrap();
1926        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1927        assert_eq!(snapshot.total_admitted_rpc_calls, 1);
1928        assert_eq!(snapshot.total_capacity_rollovers, 1);
1929        primary_server.join().unwrap();
1930        fallback_server.join().unwrap();
1931    }
1932
1933    #[tokio::test]
1934    async fn pinned_endpoint_lease_shares_rate_budget_and_never_fails_over() {
1935        let (primary_url, primary_server) = fixed_http_server(vec![
1936            r#"{"jsonrpc":"2.0","id":32,"result":"0x1"}"#,
1937            r#"{"jsonrpc":"2.0","id":33,"result":"0x2"}"#,
1938        ]);
1939        let fallback_url = Url::parse("http://127.0.0.1:1").unwrap();
1940        let transport = LoadBalancedTransport::builder_with_endpoints(vec![
1941            EndpointConfig::new(primary_url, Weight(1))
1942                .with_id("quicknode")
1943                .with_flashblocks(true)
1944                .with_max_requests_per_second(1),
1945            EndpointConfig::new(fallback_url, Weight(1)).with_id("fallback"),
1946        ])
1947        .build();
1948        let mut initial = transport.clone();
1949        let first = alloy_json_rpc::Request::new("eth_blockNumber".to_owned(), Id::Number(32), ());
1950        let first_started_at = tokio::time::Instant::now();
1951        initial
1952            .call(RequestPacket::Single(first.serialize().unwrap()))
1953            .await
1954            .unwrap();
1955        let mut pinned = transport
1956            .flashblocks_lease("quicknode")
1957            .unwrap()
1958            .transport();
1959        let second = alloy_json_rpc::Request::new("eth_chainId".to_owned(), Id::Number(33), ());
1960
1961        pinned
1962            .call(RequestPacket::Single(second.serialize().unwrap()))
1963            .await
1964            .unwrap();
1965
1966        assert!(first_started_at.elapsed() >= Duration::from_secs(1));
1967        let snapshot = transport.endpoint_request_rate_snapshot().pop().unwrap();
1968        assert_eq!(snapshot.total_admitted_rpc_calls, 2);
1969        assert_eq!(snapshot.total_capacity_rollovers, 0);
1970        assert_eq!(snapshot.total_capacity_waits, 1);
1971        primary_server.join().unwrap();
1972    }
1973
1974    #[test]
1975    fn retryable_backend_gone() {
1976        let err = TransportErrorKind::backend_gone();
1977        assert!(LoadBalancedTransport::is_retryable(&err));
1978    }
1979
1980    #[test]
1981    fn retryable_timeout() {
1982        let err = TransportErrorKind::custom_str("request timed out");
1983        assert!(LoadBalancedTransport::is_retryable(&err));
1984    }
1985
1986    #[test]
1987    fn retryable_connection_error() {
1988        let err = TransportErrorKind::custom_str("connection refused");
1989        assert!(LoadBalancedTransport::is_retryable(&err));
1990    }
1991
1992    #[test]
1993    fn not_retryable_json_rpc_error() {
1994        use alloy_json_rpc::ErrorPayload;
1995        let payload: ErrorPayload =
1996            serde_json::from_str(r#"{"code":-32600,"message":"invalid request"}"#).unwrap();
1997        let err: TransportError = RpcError::ErrorResp(payload);
1998        assert!(!LoadBalancedTransport::is_retryable(&err));
1999    }
2000
2001    #[test]
2002    fn rate_limit_detection_429() {
2003        let err = TransportErrorKind::http_error(429, "rate limited".into());
2004        assert!(LoadBalancedTransport::is_rate_limit(&err));
2005    }
2006
2007    #[test]
2008    fn rate_limit_detection_502_is_not_rate_limit() {
2009        let err = TransportErrorKind::http_error(502, "bad gateway".into());
2010        assert!(!LoadBalancedTransport::is_rate_limit(&err));
2011    }
2012
2013    #[test]
2014    fn rate_limit_detection_custom_429() {
2015        let err = TransportErrorKind::custom_str("429 Too Many Requests");
2016        assert!(LoadBalancedTransport::is_rate_limit(&err));
2017    }
2018
2019    #[test]
2020    fn rate_limit_detection_custom_timeout_is_not_rate_limit() {
2021        let err = TransportErrorKind::custom_str("request timed out");
2022        assert!(!LoadBalancedTransport::is_rate_limit(&err));
2023    }
2024
2025    #[test]
2026    fn shared_domain_endpoints_share_throttle() {
2027        let endpoints = vec![
2028            (
2029                Url::parse("https://arb-mainnet.g.alchemy.com/v2/key1").unwrap(),
2030                Weight(100),
2031            ),
2032            (
2033                Url::parse("https://base-mainnet.g.alchemy.com/v2/key1").unwrap(),
2034                Weight(100),
2035            ),
2036            (Url::parse("https://lb.drpc.org/ogrpc").unwrap(), Weight(50)),
2037        ];
2038        let transport = LoadBalancedTransport::new(endpoints);
2039
2040        // First two endpoints are on alchemy.com — should share throttle state.
2041        assert!(Arc::ptr_eq(
2042            &transport.endpoint_throttles[0],
2043            &transport.endpoint_throttles[1]
2044        ));
2045
2046        // Third endpoint (drpc.org) should be different.
2047        assert!(!Arc::ptr_eq(
2048            &transport.endpoint_throttles[0],
2049            &transport.endpoint_throttles[2]
2050        ));
2051    }
2052}