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#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
65pub struct EndpointId(String);
66
67impl EndpointId {
68 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
120#[cfg(feature = "balancer")]
121pub enum RoutePolicy {
122 #[default]
124 Balanced,
125 Prefer(EndpointId),
127 Require(EndpointId),
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
133#[cfg(feature = "balancer")]
134pub enum EndpointRouteError {
135 UnknownEndpoint(EndpointId),
137 FlashblocksUnsupported(EndpointId),
139 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#[derive(Clone, Debug, PartialEq, Eq)]
164#[cfg(feature = "balancer")]
165pub enum FlashblocksQualificationState {
166 Unprobed,
168 Qualified {
170 chain_id: u64,
172 capabilities: Vec<String>,
174 },
175 Ineligible {
178 reason: String,
180 },
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
186#[cfg(feature = "balancer")]
187pub enum FlashblocksProbeOutcome {
188 Qualified {
190 chain_id: u64,
192 capabilities: Vec<String>,
194 },
195 Ineligible {
197 reason: String,
199 },
200}
201
202#[derive(Clone, Debug, PartialEq, Eq)]
204#[cfg(feature = "balancer")]
205pub struct EndpointRequestRateSnapshot {
206 pub endpoint_id: Option<EndpointId>,
208 pub max_requests_per_second: u32,
210 pub used_in_rolling_window: u32,
212 pub remaining_in_rolling_window: u32,
214 pub total_admitted_rpc_calls: u64,
216 pub total_capacity_rollovers: u64,
218 pub total_capacity_waits: u64,
220}
221
222#[derive(Clone, Debug, PartialEq, Eq)]
224#[cfg(feature = "balancer")]
225pub struct FlashblocksQualificationSnapshot {
226 pub attempts: u64,
228 pub successes: u64,
230 pub failures: u64,
232 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#[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#[derive(Clone, Debug)]
312#[cfg(feature = "balancer")]
313pub struct EndpointConfig {
314 pub id: Option<EndpointId>,
316 pub url: Url,
318 pub weight: Weight,
320 pub max_request_bytes: Option<usize>,
323 pub max_in_flight: Option<usize>,
326 pub max_requests_per_second: Option<u32>,
329 pub flashblocks: bool,
332}
333
334#[cfg(feature = "balancer")]
335impl EndpointConfig {
336 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 pub fn with_id(mut self, id: impl Into<EndpointId>) -> Self {
351 self.id = Some(id.into());
352 self
353 }
354
355 pub const fn with_flashblocks(mut self, supported: bool) -> Self {
357 self.flashblocks = supported;
358 self
359 }
360
361 pub const fn with_max_request_bytes(mut self, bytes: usize) -> Self {
363 self.max_request_bytes = Some(bytes);
364 self
365 }
366
367 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 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#[derive(Clone, Debug)]
406#[cfg(feature = "balancer")]
407pub struct BalancerConfig {
408 pub max_retry_rounds: u32,
411 pub initial_backoff: Duration,
413 pub max_backoff: Duration,
415 pub rate_limit_failover_delay: Duration,
417 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#[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 pub const fn config(mut self, config: BalancerConfig) -> Self {
475 self.config = config;
476 self
477 }
478
479 pub fn throttle_config(mut self, config: ThrottleConfig) -> Self {
484 self.throttle_config = Some(config);
485 self
486 }
487
488 pub const fn http_client_config(mut self, config: HttpClientConfig) -> Self {
493 self.http_client_config = Some(config);
494 self
495 }
496
497 pub fn build(self) -> LoadBalancedTransport {
503 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#[derive(Clone)]
632#[cfg(feature = "balancer")]
633pub struct LoadBalancedTransport {
634 endpoints: Arc<Vec<Http<reqwest::Client>>>,
636 endpoint_throttles: Arc<Vec<Arc<DomainThrottleState>>>,
639 endpoint_request_caps: Arc<Vec<Option<usize>>>,
641 endpoint_in_flight: Arc<Vec<Option<Arc<Semaphore>>>>,
643 endpoint_request_rate_limits: Arc<Vec<Option<Arc<RequestRateLimiter>>>>,
645 endpoint_ids: Arc<Vec<Option<EndpointId>>>,
647 endpoint_flashblocks: Arc<Vec<bool>>,
649 endpoint_index_by_id: Arc<HashMap<EndpointId, usize>>,
651 flashblocks_qualifications: Arc<RwLock<HashMap<EndpointId, FlashblocksQualification>>>,
653 cumulative_weights: Arc<Vec<u64>>,
655 total_weight: u64,
657 counter: Arc<AtomicU64>,
659 config: Arc<BalancerConfig>,
661 route_policy: RoutePolicy,
663}
664
665#[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 pub const fn endpoint_id(&self) -> &EndpointId {
681 &self.endpoint_id
682 }
683
684 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 pub fn new(endpoints: Vec<(Url, Weight)>) -> Self {
721 Self::builder(endpoints).build()
722 }
723
724 pub fn builder(endpoints: Vec<(Url, Weight)>) -> LoadBalancedTransportBuilder {
739 Self::builder_with_endpoints(endpoints.into_iter().map(EndpointConfig::from).collect())
740 }
741
742 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 pub const fn route_policy(&self) -> &RoutePolicy {
754 &self.route_policy
755 }
756
757 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 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 pub fn prefer_endpoint(&self, id: impl Into<EndpointId>) -> Result<Self, EndpointRouteError> {
792 self.with_route_policy(RoutePolicy::Prefer(id.into()))
793 }
794
795 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 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 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 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 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 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 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 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 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 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 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 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 assert!(Arc::ptr_eq(
2042 &transport.endpoint_throttles[0],
2043 &transport.endpoint_throttles[1]
2044 ));
2045
2046 assert!(!Arc::ptr_eq(
2048 &transport.endpoint_throttles[0],
2049 &transport.endpoint_throttles[2]
2050 ));
2051 }
2052}