Skip to main content

perpcity_sdk/transport/
config.rs

1//! Transport configuration with builder pattern.
2//!
3//! Configure multi-endpoint RPC transport with per-endpoint timeouts,
4//! retry policies, circuit breaker thresholds, and routing strategies.
5//!
6//! # Example
7//!
8//! ```
9//! use perpcity_sdk::transport::config::{TransportConfig, Strategy};
10//! use std::time::Duration;
11//!
12//! let config = TransportConfig::builder()
13//!     .endpoint("https://mainnet.base.org")
14//!     .endpoint("https://base-rpc.publicnode.com")
15//!     .ws_endpoint("wss://base-rpc.publicnode.com")
16//!     .strategy(Strategy::LatencyBased)
17//!     .request_timeout(Duration::from_millis(2000))
18//!     .build()
19//!     .unwrap();
20//!
21//! assert_eq!(config.http_endpoints.len(), 2);
22//! assert!(config.ws_endpoint.is_some());
23//! ```
24
25use std::time::Duration;
26
27use crate::errors::PerpCityError;
28
29/// Endpoint selection strategy for routing RPC requests.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum Strategy {
32    /// Cycle through healthy endpoints sequentially.
33    RoundRobin,
34    /// Pick the endpoint with the lowest observed latency.
35    #[default]
36    LatencyBased,
37    /// Fan out reads to `fan_out` endpoints, take the fastest response.
38    /// Writes always go to a single best endpoint.
39    Hedged { fan_out: usize },
40}
41
42/// Circuit breaker configuration per endpoint.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct CircuitBreakerConfig {
45    /// Number of consecutive failures before opening the circuit.
46    pub failure_threshold: u32,
47    /// Time to wait in Open state before probing (HalfOpen).
48    pub recovery_timeout: Duration,
49    /// Maximum concurrent probe requests allowed in HalfOpen state.
50    pub half_open_max_requests: u32,
51}
52
53impl Default for CircuitBreakerConfig {
54    fn default() -> Self {
55        Self {
56            failure_threshold: 3,
57            recovery_timeout: Duration::from_secs(30),
58            half_open_max_requests: 1,
59        }
60    }
61}
62
63/// Retry configuration for read operations. Writes are never retried.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct RetryConfig {
66    /// Maximum number of retry attempts (0 = no retries, just the initial try).
67    pub max_retries: u32,
68    /// Base delay between retries. Scaled by 2^attempt for exponential backoff.
69    pub base_delay: Duration,
70}
71
72impl Default for RetryConfig {
73    fn default() -> Self {
74        Self {
75            max_retries: 2,
76            base_delay: Duration::from_millis(100),
77        }
78    }
79}
80
81/// Complete transport configuration.
82#[derive(Debug, Clone)]
83pub struct TransportConfig {
84    /// HTTP RPC endpoint URLs.
85    pub http_endpoints: Vec<String>,
86    /// Optional WebSocket endpoint URL for subscriptions.
87    pub ws_endpoint: Option<String>,
88    /// Per-request timeout.
89    pub request_timeout: Duration,
90    /// Endpoint selection strategy.
91    pub strategy: Strategy,
92    /// Circuit breaker settings (applied per endpoint).
93    pub circuit_breaker: CircuitBreakerConfig,
94    /// Retry settings for read operations.
95    pub retry: RetryConfig,
96}
97
98impl TransportConfig {
99    /// Create a new builder for `TransportConfig`.
100    pub fn builder() -> TransportConfigBuilder {
101        TransportConfigBuilder::default()
102    }
103}
104
105/// Builder for [`TransportConfig`].
106#[derive(Debug, Clone)]
107pub struct TransportConfigBuilder {
108    http_endpoints: Vec<String>,
109    ws_endpoint: Option<String>,
110    request_timeout: Duration,
111    strategy: Strategy,
112    circuit_breaker: CircuitBreakerConfig,
113    retry: RetryConfig,
114}
115
116impl Default for TransportConfigBuilder {
117    fn default() -> Self {
118        Self {
119            http_endpoints: Vec::new(),
120            ws_endpoint: None,
121            request_timeout: Duration::from_secs(5),
122            strategy: Strategy::default(),
123            circuit_breaker: CircuitBreakerConfig::default(),
124            retry: RetryConfig::default(),
125        }
126    }
127}
128
129impl TransportConfigBuilder {
130    /// Add an HTTP RPC endpoint URL.
131    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
132        self.http_endpoints.push(url.into());
133        self
134    }
135
136    /// Set the WebSocket endpoint URL for subscriptions.
137    pub fn ws_endpoint(mut self, url: impl Into<String>) -> Self {
138        self.ws_endpoint = Some(url.into());
139        self
140    }
141
142    /// Set the per-request timeout.
143    pub fn request_timeout(mut self, timeout: Duration) -> Self {
144        self.request_timeout = timeout;
145        self
146    }
147
148    /// Set the endpoint selection strategy.
149    pub fn strategy(mut self, strategy: Strategy) -> Self {
150        self.strategy = strategy;
151        self
152    }
153
154    /// Set the circuit breaker configuration.
155    pub fn circuit_breaker(mut self, config: CircuitBreakerConfig) -> Self {
156        self.circuit_breaker = config;
157        self
158    }
159
160    /// Set the retry configuration for read operations.
161    pub fn retry(mut self, config: RetryConfig) -> Self {
162        self.retry = config;
163        self
164    }
165
166    /// Build the [`TransportConfig`].
167    ///
168    /// Returns an error if no HTTP endpoints are configured.
169    pub fn build(self) -> crate::Result<TransportConfig> {
170        if self.http_endpoints.is_empty() {
171            return Err(PerpCityError::InvalidConfig {
172                reason: "no HTTP endpoints configured".into(),
173            });
174        }
175        if let Strategy::Hedged { fan_out } = self.strategy
176            && fan_out < 2
177        {
178            return Err(PerpCityError::InvalidConfig {
179                reason: "hedged strategy requires fan_out >= 2".into(),
180            });
181        }
182        Ok(TransportConfig {
183            http_endpoints: self.http_endpoints,
184            ws_endpoint: self.ws_endpoint,
185            request_timeout: self.request_timeout,
186            strategy: self.strategy,
187            circuit_breaker: self.circuit_breaker,
188            retry: self.retry,
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn builder_defaults() {
199        let config = TransportConfig::builder()
200            .endpoint("https://rpc1.example.com")
201            .build()
202            .unwrap();
203        assert_eq!(config.http_endpoints.len(), 1);
204        assert!(config.ws_endpoint.is_none());
205        assert_eq!(config.request_timeout, Duration::from_secs(5));
206        assert_eq!(config.strategy, Strategy::LatencyBased);
207        assert_eq!(config.circuit_breaker.failure_threshold, 3);
208        assert_eq!(config.retry.max_retries, 2);
209    }
210
211    #[test]
212    fn builder_all_options() {
213        let config = TransportConfig::builder()
214            .endpoint("https://rpc1.example.com")
215            .endpoint("https://rpc2.example.com")
216            .ws_endpoint("wss://ws.example.com")
217            .request_timeout(Duration::from_millis(500))
218            .strategy(Strategy::Hedged { fan_out: 3 })
219            .circuit_breaker(CircuitBreakerConfig {
220                failure_threshold: 5,
221                recovery_timeout: Duration::from_secs(60),
222                half_open_max_requests: 2,
223            })
224            .retry(RetryConfig {
225                max_retries: 5,
226                base_delay: Duration::from_millis(50),
227            })
228            .build()
229            .unwrap();
230
231        assert_eq!(config.http_endpoints.len(), 2);
232        assert_eq!(config.ws_endpoint.as_deref(), Some("wss://ws.example.com"));
233        assert_eq!(config.request_timeout, Duration::from_millis(500));
234        assert!(matches!(config.strategy, Strategy::Hedged { fan_out: 3 }));
235        assert_eq!(config.circuit_breaker.failure_threshold, 5);
236        assert_eq!(config.retry.max_retries, 5);
237    }
238
239    #[test]
240    fn no_endpoints_errors() {
241        let result = TransportConfig::builder().build();
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn hedged_fan_out_one_errors() {
247        let result = TransportConfig::builder()
248            .endpoint("https://rpc1.example.com")
249            .strategy(Strategy::Hedged { fan_out: 1 })
250            .build();
251        assert!(result.is_err());
252    }
253}