openzeppelin_relayer/services/provider/
mod.rs

1use std::num::ParseIntError;
2
3use crate::config::ServerConfig;
4use crate::models::{EvmNetwork, RpcConfig, SolanaNetwork, StellarNetwork};
5use serde::Serialize;
6use thiserror::Error;
7
8use alloy::transports::RpcError;
9
10pub mod evm;
11pub use evm::*;
12
13mod solana;
14pub use solana::*;
15
16mod stellar;
17pub use stellar::*;
18
19mod retry;
20pub use retry::*;
21
22pub mod rpc_health_store;
23pub mod rpc_selector;
24
25pub use rpc_health_store::{RpcConfigMetadata, RpcHealthStore};
26
27/// Configuration for creating a provider instance.
28///
29/// This struct encapsulates all the parameters needed to create a provider,
30/// making the API cleaner and easier to maintain.
31#[derive(Debug, Clone)]
32pub struct ProviderConfig {
33    /// RPC endpoint configurations (URLs and weights)
34    pub rpc_configs: Vec<RpcConfig>,
35    /// Timeout duration in seconds for RPC requests
36    pub timeout_seconds: u64,
37    /// Number of consecutive failures before pausing a provider
38    pub failure_threshold: u32,
39    /// Duration in seconds to pause a provider after reaching failure threshold
40    pub pause_duration_secs: u64,
41    /// Duration in seconds after which failures are considered stale and reset
42    pub failure_expiration_secs: u64,
43}
44
45impl ProviderConfig {
46    /// Creates a new `ProviderConfig` from individual parameters.
47    ///
48    /// # Arguments
49    /// * `rpc_configs` - RPC endpoint configurations
50    /// * `timeout_seconds` - Timeout duration in seconds
51    /// * `failure_threshold` - Number of consecutive failures before pausing
52    /// * `pause_duration_secs` - Duration in seconds to pause after threshold
53    /// * `failure_expiration_secs` - Duration in seconds after which failures are considered stale
54    pub fn new(
55        rpc_configs: Vec<RpcConfig>,
56        timeout_seconds: u64,
57        failure_threshold: u32,
58        pause_duration_secs: u64,
59        failure_expiration_secs: u64,
60    ) -> Self {
61        Self {
62            rpc_configs,
63            timeout_seconds,
64            failure_threshold,
65            pause_duration_secs,
66            failure_expiration_secs,
67        }
68    }
69
70    /// Creates a `ProviderConfig` from `ServerConfig` with the given RPC configs.
71    ///
72    /// This is a convenience method that extracts provider-related configuration
73    /// from the server configuration.
74    ///
75    /// # Arguments
76    /// * `server_config` - The server configuration
77    /// * `rpc_configs` - RPC endpoint configurations
78    pub fn from_server_config(server_config: &ServerConfig, rpc_configs: Vec<RpcConfig>) -> Self {
79        let timeout_seconds = server_config.rpc_timeout_ms / 1000; // Convert ms to s
80        Self {
81            rpc_configs,
82            timeout_seconds,
83            failure_threshold: server_config.provider_failure_threshold,
84            pause_duration_secs: server_config.provider_pause_duration_secs,
85            failure_expiration_secs: server_config.provider_failure_expiration_secs,
86        }
87    }
88
89    /// Creates a `ProviderConfig` from environment variables with the given RPC configs.
90    ///
91    /// This loads configuration from `ServerConfig::from_env()`.
92    ///
93    /// # Arguments
94    /// * `rpc_configs` - RPC endpoint configurations
95    pub fn from_env(rpc_configs: Vec<RpcConfig>) -> Self {
96        let server_config = ServerConfig::from_env();
97        Self::from_server_config(&server_config, rpc_configs)
98    }
99}
100
101#[derive(Error, Debug, Serialize)]
102pub enum ProviderError {
103    #[error("RPC client error: {0}")]
104    SolanaRpcError(#[from] SolanaProviderError),
105    #[error("Invalid address: {0}")]
106    InvalidAddress(String),
107    #[error("Network configuration error: {0}")]
108    NetworkConfiguration(String),
109    #[error("Request timeout")]
110    Timeout,
111    #[error("Rate limited (HTTP 429)")]
112    RateLimited,
113    #[error("Bad gateway (HTTP 502)")]
114    BadGateway,
115    #[error("Request error (HTTP {status_code}): {error}")]
116    RequestError { error: String, status_code: u16 },
117    #[error("JSON-RPC error (code {code}): {message}")]
118    RpcErrorCode { code: i64, message: String },
119    #[error("Transport error: {0}")]
120    TransportError(String),
121    #[error("Other provider error: {0}")]
122    Other(String),
123}
124
125impl ProviderError {
126    /// Determines if this error is transient (can retry) or permanent (should fail).
127    pub fn is_transient(&self) -> bool {
128        is_retriable_error(self)
129    }
130}
131
132impl From<hex::FromHexError> for ProviderError {
133    fn from(err: hex::FromHexError) -> Self {
134        ProviderError::InvalidAddress(err.to_string())
135    }
136}
137
138impl From<std::net::AddrParseError> for ProviderError {
139    fn from(err: std::net::AddrParseError) -> Self {
140        ProviderError::NetworkConfiguration(format!("Invalid network address: {err}"))
141    }
142}
143
144impl From<ParseIntError> for ProviderError {
145    fn from(err: ParseIntError) -> Self {
146        ProviderError::Other(format!("Number parsing error: {err}"))
147    }
148}
149
150/// Categorizes a reqwest error into an appropriate `ProviderError` variant.
151///
152/// This function analyzes the given reqwest error and maps it to a specific
153/// `ProviderError` variant based on the error's properties:
154/// - Timeout errors become `ProviderError::Timeout`
155/// - HTTP 429 responses become `ProviderError::RateLimited`
156/// - HTTP 502 responses become `ProviderError::BadGateway`
157/// - All other errors become `ProviderError::Other` with the error message
158///
159/// # Arguments
160///
161/// * `err` - A reference to the reqwest error to categorize
162///
163/// # Returns
164///
165/// The appropriate `ProviderError` variant based on the error type
166fn categorize_reqwest_error(err: &reqwest::Error) -> ProviderError {
167    if err.is_timeout() {
168        return ProviderError::Timeout;
169    }
170
171    if let Some(status) = err.status() {
172        match status.as_u16() {
173            429 => return ProviderError::RateLimited,
174            502 => return ProviderError::BadGateway,
175            _ => {
176                return ProviderError::RequestError {
177                    error: err.to_string(),
178                    status_code: status.as_u16(),
179                }
180            }
181        }
182    }
183
184    ProviderError::Other(err.to_string())
185}
186
187impl From<reqwest::Error> for ProviderError {
188    fn from(err: reqwest::Error) -> Self {
189        categorize_reqwest_error(&err)
190    }
191}
192
193impl From<&reqwest::Error> for ProviderError {
194    fn from(err: &reqwest::Error) -> Self {
195        categorize_reqwest_error(err)
196    }
197}
198
199impl From<eyre::Report> for ProviderError {
200    fn from(err: eyre::Report) -> Self {
201        // Downcast to known error types first
202        if let Some(reqwest_err) = err.downcast_ref::<reqwest::Error>() {
203            return ProviderError::from(reqwest_err);
204        }
205
206        // Default to Other for unknown error types
207        ProviderError::Other(err.to_string())
208    }
209}
210
211// Add conversion from String to ProviderError
212impl From<String> for ProviderError {
213    fn from(error: String) -> Self {
214        ProviderError::Other(error)
215    }
216}
217
218// Generic implementation for all RpcError types
219impl<E> From<RpcError<E>> for ProviderError
220where
221    E: std::fmt::Display + std::any::Any + 'static,
222{
223    fn from(err: RpcError<E>) -> Self {
224        match err {
225            RpcError::Transport(transport_err) => {
226                // First check if it's a reqwest::Error using downcasting
227                if let Some(reqwest_err) =
228                    (&transport_err as &dyn std::any::Any).downcast_ref::<reqwest::Error>()
229                {
230                    return categorize_reqwest_error(reqwest_err);
231                }
232
233                ProviderError::TransportError(transport_err.to_string())
234            }
235            RpcError::ErrorResp(json_rpc_err) => ProviderError::RpcErrorCode {
236                code: json_rpc_err.code,
237                message: json_rpc_err.message.to_string(),
238            },
239            _ => ProviderError::Other(format!("Other RPC error: {err}")),
240        }
241    }
242}
243
244// Implement From for RpcSelectorError
245impl From<rpc_selector::RpcSelectorError> for ProviderError {
246    fn from(err: rpc_selector::RpcSelectorError) -> Self {
247        ProviderError::NetworkConfiguration(format!("RPC selector error: {err}"))
248    }
249}
250
251pub trait NetworkConfiguration: Sized {
252    type Provider;
253
254    fn public_rpc_urls(&self) -> Vec<RpcConfig>;
255
256    /// Creates a new provider instance using the provided configuration.
257    ///
258    /// # Arguments
259    /// * `config` - Provider configuration containing RPC configs and settings
260    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError>;
261}
262
263impl NetworkConfiguration for EvmNetwork {
264    type Provider = EvmProvider;
265
266    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
267        self.rpc_urls.clone()
268    }
269
270    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
271        EvmProvider::new(config)
272    }
273}
274
275impl NetworkConfiguration for SolanaNetwork {
276    type Provider = SolanaProvider;
277
278    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
279        self.rpc_urls.clone()
280    }
281
282    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
283        SolanaProvider::new(config)
284    }
285}
286
287impl NetworkConfiguration for StellarNetwork {
288    type Provider = StellarProvider;
289
290    fn public_rpc_urls(&self) -> Vec<RpcConfig> {
291        self.rpc_urls.clone()
292    }
293
294    fn new_provider(config: ProviderConfig) -> Result<Self::Provider, ProviderError> {
295        StellarProvider::new(config)
296    }
297}
298
299/// Creates a network-specific provider instance based on the provided configuration.
300///
301/// # Type Parameters
302///
303/// * `N`: The type of the network, which must implement the `NetworkConfiguration` trait.
304///   This determines the specific provider type (`N::Provider`) and how to obtain
305///   public RPC URLs.
306///
307/// # Arguments
308///
309/// * `network`: A reference to the network configuration object (`&N`).
310/// * `custom_rpc_urls`: An `Option<Vec<RpcConfig>>`. If `Some` and not empty, these URLs
311///   are used to configure the provider. If `None` or `Some` but empty, the function
312///   falls back to using the public RPC URLs defined by the `network`'s
313///   `NetworkConfiguration` implementation.
314///
315/// # Returns
316///
317/// * `Ok(N::Provider)`: An instance of the network-specific provider on success.
318/// * `Err(ProviderError)`: An error if configuration fails, such as when no custom URLs
319///   are provided and the network has no public RPC URLs defined
320///   (`ProviderError::NetworkConfiguration`).
321pub fn get_network_provider<N: NetworkConfiguration>(
322    network: &N,
323    custom_rpc_urls: Option<Vec<RpcConfig>>,
324) -> Result<N::Provider, ProviderError> {
325    let rpc_urls = match custom_rpc_urls {
326        Some(configs) if !configs.is_empty() => configs,
327        _ => {
328            let configs = network.public_rpc_urls();
329            if configs.is_empty() {
330                return Err(ProviderError::NetworkConfiguration(
331                    "No public RPC URLs available for this network".to_string(),
332                ));
333            }
334            configs
335        }
336    };
337
338    let provider_config = ProviderConfig::from_env(rpc_urls);
339    N::new_provider(provider_config)
340}
341
342/// Determines if an HTTP status code indicates the provider should be marked as failed.
343///
344/// This is a low-level function that can be reused across different error types.
345///
346/// Returns `true` for:
347/// - 5xx Server Errors (500-599) - RPC node is having issues
348/// - Specific 4xx Client Errors that indicate provider issues:
349///   - 401 (Unauthorized) - auth required but not provided
350///   - 403 (Forbidden) - node is blocking requests or auth issues
351///   - 404 (Not Found) - endpoint doesn't exist or misconfigured
352///   - 410 (Gone) - endpoint permanently removed
353pub fn should_mark_provider_failed_by_status_code(status_code: u16) -> bool {
354    match status_code {
355        // 5xx Server Errors - RPC node is having issues
356        500..=599 => true,
357
358        // 4xx Client Errors that indicate we can't use this provider
359        401 => true, // Unauthorized - auth required but not provided
360        403 => true, // Forbidden - node is blocking requests or auth issues
361        404 => true, // Not Found - endpoint doesn't exist or misconfigured
362        410 => true, // Gone - endpoint permanently removed
363
364        _ => false,
365    }
366}
367
368pub fn should_mark_provider_failed(error: &ProviderError) -> bool {
369    match error {
370        ProviderError::RequestError { status_code, .. } => {
371            should_mark_provider_failed_by_status_code(*status_code)
372        }
373        _ => false,
374    }
375}
376
377// Errors that are retriable
378pub fn is_retriable_error(error: &ProviderError) -> bool {
379    match error {
380        // HTTP-level errors that are retriable
381        ProviderError::Timeout
382        | ProviderError::RateLimited
383        | ProviderError::BadGateway
384        | ProviderError::TransportError(_) => true,
385
386        ProviderError::RequestError { status_code, .. } => {
387            match *status_code {
388                // Non-retriable 5xx: persistent server-side issues
389                501 | 505 => false, // Not Implemented, HTTP Version Not Supported
390
391                // Retriable 5xx: temporary server-side issues
392                500 | 502..=504 | 506..=599 => true,
393
394                // Retriable 4xx: timeout or rate-limit related
395                408 | 425 | 429 => true,
396
397                // Non-retriable 4xx: client errors
398                400..=499 => false,
399
400                // Other status codes: not retriable
401                _ => false,
402            }
403        }
404
405        // JSON-RPC error codes (EIP-1474)
406        ProviderError::RpcErrorCode { code, .. } => {
407            match code {
408                // -32002: Resource unavailable (temporary state)
409                -32002 => true,
410                // -32005: Limit exceeded / rate limited
411                -32005 => true,
412                // -32603: Internal error (may be temporary)
413                -32603 => true,
414                // -32000: Invalid input
415                -32000 => false,
416                // -32001: Resource not found
417                -32001 => false,
418                // -32003: Transaction rejected
419                -32003 => false,
420                // -32004: Method not supported
421                -32004 => false,
422
423                // Standard JSON-RPC 2.0 errors (not retriable)
424                // -32700: Parse error
425                // -32600: Invalid request
426                // -32601: Method not found
427                // -32602: Invalid params
428                -32700..=-32600 => false,
429
430                // All other error codes: not retriable by default
431                _ => false,
432            }
433        }
434
435        ProviderError::SolanaRpcError(err) => err.is_transient(),
436
437        // Any other errors: check message for network-related issues
438        _ => {
439            let err_msg = format!("{error}");
440            let msg_lower = err_msg.to_lowercase();
441            msg_lower.contains("timeout")
442                || msg_lower.contains("connection")
443                || msg_lower.contains("reset")
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use lazy_static::lazy_static;
452    use std::env;
453    use std::sync::Mutex;
454    use std::time::Duration;
455
456    // Use a mutex to ensure tests don't run in parallel when modifying env vars
457    lazy_static! {
458        static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
459    }
460
461    fn setup_test_env() {
462        env::set_var("API_KEY", "7EF1CB7C-5003-4696-B384-C72AF8C3E15D"); // noboost
463        env::set_var("REDIS_URL", "redis://localhost:6379");
464        env::set_var("RPC_TIMEOUT_MS", "5000");
465    }
466
467    fn cleanup_test_env() {
468        env::remove_var("API_KEY");
469        env::remove_var("REDIS_URL");
470        env::remove_var("RPC_TIMEOUT_MS");
471    }
472
473    fn create_test_evm_network() -> EvmNetwork {
474        EvmNetwork {
475            network: "test-evm".to_string(),
476            rpc_urls: vec![RpcConfig::new("https://rpc.example.com".to_string())],
477            explorer_urls: None,
478            average_blocktime_ms: 12000,
479            is_testnet: true,
480            tags: vec![],
481            chain_id: 1337,
482            required_confirmations: 1,
483            features: vec![],
484            symbol: "ETH".to_string(),
485            gas_price_cache: None,
486        }
487    }
488
489    fn create_test_solana_network(network_str: &str) -> SolanaNetwork {
490        SolanaNetwork {
491            network: network_str.to_string(),
492            rpc_urls: vec![RpcConfig::new("https://api.testnet.solana.com".to_string())],
493            explorer_urls: None,
494            average_blocktime_ms: 400,
495            is_testnet: true,
496            tags: vec![],
497        }
498    }
499
500    fn create_test_stellar_network() -> StellarNetwork {
501        StellarNetwork {
502            network: "testnet".to_string(),
503            rpc_urls: vec![RpcConfig::new(
504                "https://soroban-testnet.stellar.org".to_string(),
505            )],
506            explorer_urls: None,
507            average_blocktime_ms: 5000,
508            is_testnet: true,
509            tags: vec![],
510            passphrase: "Test SDF Network ; September 2015".to_string(),
511            horizon_url: Some("https://horizon-testnet.stellar.org".to_string()),
512        }
513    }
514
515    #[test]
516    fn test_from_hex_error() {
517        let hex_error = hex::FromHexError::OddLength;
518        let provider_error: ProviderError = hex_error.into();
519        assert!(matches!(provider_error, ProviderError::InvalidAddress(_)));
520    }
521
522    #[test]
523    fn test_from_addr_parse_error() {
524        let addr_error = "invalid:address"
525            .parse::<std::net::SocketAddr>()
526            .unwrap_err();
527        let provider_error: ProviderError = addr_error.into();
528        assert!(matches!(
529            provider_error,
530            ProviderError::NetworkConfiguration(_)
531        ));
532    }
533
534    #[test]
535    fn test_from_parse_int_error() {
536        let parse_error = "not_a_number".parse::<u64>().unwrap_err();
537        let provider_error: ProviderError = parse_error.into();
538        assert!(matches!(provider_error, ProviderError::Other(_)));
539    }
540
541    #[actix_rt::test]
542    async fn test_categorize_reqwest_error_timeout() {
543        let client = reqwest::Client::new();
544        let timeout_err = client
545            .get("http://example.com")
546            .timeout(Duration::from_nanos(1))
547            .send()
548            .await
549            .unwrap_err();
550
551        assert!(timeout_err.is_timeout());
552
553        let provider_error = categorize_reqwest_error(&timeout_err);
554        assert!(matches!(provider_error, ProviderError::Timeout));
555    }
556
557    #[actix_rt::test]
558    async fn test_categorize_reqwest_error_rate_limited() {
559        let mut mock_server = mockito::Server::new_async().await;
560
561        let _mock = mock_server
562            .mock("GET", mockito::Matcher::Any)
563            .with_status(429)
564            .create_async()
565            .await;
566
567        let client = reqwest::Client::new();
568        let response = client
569            .get(mock_server.url())
570            .send()
571            .await
572            .expect("Failed to get response");
573
574        let err = response
575            .error_for_status()
576            .expect_err("Expected error for status 429");
577
578        assert!(err.status().is_some());
579        assert_eq!(err.status().unwrap().as_u16(), 429);
580
581        let provider_error = categorize_reqwest_error(&err);
582        assert!(matches!(provider_error, ProviderError::RateLimited));
583    }
584
585    #[actix_rt::test]
586    async fn test_categorize_reqwest_error_bad_gateway() {
587        let mut mock_server = mockito::Server::new_async().await;
588
589        let _mock = mock_server
590            .mock("GET", mockito::Matcher::Any)
591            .with_status(502)
592            .create_async()
593            .await;
594
595        let client = reqwest::Client::new();
596        let response = client
597            .get(mock_server.url())
598            .send()
599            .await
600            .expect("Failed to get response");
601
602        let err = response
603            .error_for_status()
604            .expect_err("Expected error for status 502");
605
606        assert!(err.status().is_some());
607        assert_eq!(err.status().unwrap().as_u16(), 502);
608
609        let provider_error = categorize_reqwest_error(&err);
610        assert!(matches!(provider_error, ProviderError::BadGateway));
611    }
612
613    #[actix_rt::test]
614    async fn test_categorize_reqwest_error_other() {
615        let client = reqwest::Client::new();
616        let err = client
617            .get("http://non-existent-host-12345.local")
618            .send()
619            .await
620            .unwrap_err();
621
622        assert!(!err.is_timeout());
623        assert!(err.status().is_none()); // No status code
624
625        let provider_error = categorize_reqwest_error(&err);
626        assert!(matches!(provider_error, ProviderError::Other(_)));
627    }
628
629    #[test]
630    fn test_from_eyre_report_other_error() {
631        let eyre_error: eyre::Report = eyre::eyre!("Generic error");
632        let provider_error: ProviderError = eyre_error.into();
633        assert!(matches!(provider_error, ProviderError::Other(_)));
634    }
635
636    #[test]
637    fn test_get_evm_network_provider_valid_network() {
638        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
639        setup_test_env();
640
641        let network = create_test_evm_network();
642        let result = get_network_provider(&network, None);
643
644        cleanup_test_env();
645        assert!(result.is_ok());
646    }
647
648    #[test]
649    fn test_get_evm_network_provider_with_custom_urls() {
650        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
651        setup_test_env();
652
653        let network = create_test_evm_network();
654        let custom_urls = vec![
655            RpcConfig {
656                url: "https://custom-rpc1.example.com".to_string(),
657                weight: 1,
658                ..Default::default()
659            },
660            RpcConfig {
661                url: "https://custom-rpc2.example.com".to_string(),
662                weight: 1,
663                ..Default::default()
664            },
665        ];
666        let result = get_network_provider(&network, Some(custom_urls));
667
668        cleanup_test_env();
669        assert!(result.is_ok());
670    }
671
672    #[test]
673    fn test_get_evm_network_provider_with_empty_custom_urls() {
674        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
675        setup_test_env();
676
677        let network = create_test_evm_network();
678        let custom_urls: Vec<RpcConfig> = vec![];
679        let result = get_network_provider(&network, Some(custom_urls));
680
681        cleanup_test_env();
682        assert!(result.is_ok()); // Should fall back to public URLs
683    }
684
685    #[test]
686    fn test_get_solana_network_provider_valid_network_mainnet_beta() {
687        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
688        setup_test_env();
689
690        let network = create_test_solana_network("mainnet-beta");
691        let result = get_network_provider(&network, None);
692
693        cleanup_test_env();
694        assert!(result.is_ok());
695    }
696
697    #[test]
698    fn test_get_solana_network_provider_valid_network_testnet() {
699        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
700        setup_test_env();
701
702        let network = create_test_solana_network("testnet");
703        let result = get_network_provider(&network, None);
704
705        cleanup_test_env();
706        assert!(result.is_ok());
707    }
708
709    #[test]
710    fn test_get_solana_network_provider_with_custom_urls() {
711        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
712        setup_test_env();
713
714        let network = create_test_solana_network("testnet");
715        let custom_urls = vec![
716            RpcConfig {
717                url: "https://custom-rpc1.example.com".to_string(),
718                weight: 1,
719                ..Default::default()
720            },
721            RpcConfig {
722                url: "https://custom-rpc2.example.com".to_string(),
723                weight: 1,
724                ..Default::default()
725            },
726        ];
727        let result = get_network_provider(&network, Some(custom_urls));
728
729        cleanup_test_env();
730        assert!(result.is_ok());
731    }
732
733    #[test]
734    fn test_get_solana_network_provider_with_empty_custom_urls() {
735        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
736        setup_test_env();
737
738        let network = create_test_solana_network("testnet");
739        let custom_urls: Vec<RpcConfig> = vec![];
740        let result = get_network_provider(&network, Some(custom_urls));
741
742        cleanup_test_env();
743        assert!(result.is_ok()); // Should fall back to public URLs
744    }
745
746    // Tests for Stellar Network Provider
747    #[test]
748    fn test_get_stellar_network_provider_valid_network_fallback_public() {
749        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
750        setup_test_env();
751
752        let network = create_test_stellar_network();
753        let result = get_network_provider(&network, None); // No custom URLs
754
755        cleanup_test_env();
756        assert!(result.is_ok()); // Should fall back to public URLs for testnet
757                                 // StellarProvider::new will use the first public URL: https://soroban-testnet.stellar.org
758    }
759
760    #[test]
761    fn test_get_stellar_network_provider_with_custom_urls() {
762        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
763        setup_test_env();
764
765        let network = create_test_stellar_network();
766        let custom_urls = vec![
767            RpcConfig::new("https://custom-stellar-rpc1.example.com".to_string()),
768            RpcConfig::with_weight("http://custom-stellar-rpc2.example.com".to_string(), 50)
769                .unwrap(),
770        ];
771        let result = get_network_provider(&network, Some(custom_urls));
772
773        cleanup_test_env();
774        assert!(result.is_ok());
775        // StellarProvider::new will pick custom-stellar-rpc1 (default weight 100) over custom-stellar-rpc2 (weight 50)
776    }
777
778    #[test]
779    fn test_get_stellar_network_provider_with_empty_custom_urls_fallback() {
780        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
781        setup_test_env();
782
783        let network = create_test_stellar_network();
784        let custom_urls: Vec<RpcConfig> = vec![]; // Empty custom URLs
785        let result = get_network_provider(&network, Some(custom_urls));
786
787        cleanup_test_env();
788        assert!(result.is_ok()); // Should fall back to public URLs for mainnet
789                                 // StellarProvider::new will use the first public URL: https://horizon.stellar.org
790    }
791
792    #[test]
793    fn test_get_stellar_network_provider_custom_urls_with_zero_weight() {
794        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
795        setup_test_env();
796
797        let network = create_test_stellar_network();
798        let custom_urls = vec![
799            RpcConfig::with_weight("http://zero-weight-rpc.example.com".to_string(), 0).unwrap(),
800            RpcConfig::new("http://active-rpc.example.com".to_string()), // Default weight 100
801        ];
802        let result = get_network_provider(&network, Some(custom_urls));
803        cleanup_test_env();
804        assert!(result.is_ok()); // active-rpc should be chosen
805    }
806
807    #[test]
808    fn test_get_stellar_network_provider_all_custom_urls_zero_weight_fallback() {
809        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
810        setup_test_env();
811
812        let network = create_test_stellar_network();
813        let custom_urls = vec![
814            RpcConfig::with_weight("http://zero1.example.com".to_string(), 0).unwrap(),
815            RpcConfig::with_weight("http://zero2.example.com".to_string(), 0).unwrap(),
816        ];
817        // Since StellarProvider::new filters out zero-weight URLs, and if the list becomes empty,
818        // get_network_provider does NOT re-trigger fallback to public. Instead, StellarProvider::new itself will error.
819        // The current get_network_provider logic passes the custom_urls to N::new_provider if Some and not empty.
820        // If custom_urls becomes effectively empty *inside* N::new_provider (like StellarProvider::new after filtering weights),
821        // then N::new_provider is responsible for erroring or handling.
822        let result = get_network_provider(&network, Some(custom_urls));
823        cleanup_test_env();
824        assert!(result.is_err());
825        match result.unwrap_err() {
826            ProviderError::NetworkConfiguration(msg) => {
827                assert!(msg.contains("No active RPC configurations provided"));
828            }
829            _ => panic!("Unexpected error type"),
830        }
831    }
832
833    #[test]
834    fn test_provider_error_rpc_error_code_variant() {
835        let error = ProviderError::RpcErrorCode {
836            code: -32000,
837            message: "insufficient funds".to_string(),
838        };
839        let error_string = format!("{error}");
840        assert!(error_string.contains("-32000"));
841        assert!(error_string.contains("insufficient funds"));
842    }
843
844    #[test]
845    fn test_get_stellar_network_provider_invalid_custom_url_scheme() {
846        let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
847        setup_test_env();
848        let network = create_test_stellar_network();
849        let custom_urls = vec![RpcConfig::new("ftp://custom-ftp.example.com".to_string())];
850        let result = get_network_provider(&network, Some(custom_urls));
851        cleanup_test_env();
852        assert!(result.is_err());
853        match result.unwrap_err() {
854            ProviderError::NetworkConfiguration(msg) => {
855                // This error comes from RpcConfig::validate_list inside StellarProvider::new
856                assert!(msg.contains("Invalid URL scheme"));
857            }
858            _ => panic!("Unexpected error type"),
859        }
860    }
861
862    #[test]
863    fn test_should_mark_provider_failed_server_errors() {
864        // 5xx errors should mark provider as failed
865        for status_code in 500..=599 {
866            let error = ProviderError::RequestError {
867                error: format!("Server error {status_code}"),
868                status_code,
869            };
870            assert!(
871                should_mark_provider_failed(&error),
872                "Status code {status_code} should mark provider as failed"
873            );
874        }
875    }
876
877    #[test]
878    fn test_should_mark_provider_failed_auth_errors() {
879        // Authentication/authorization errors should mark provider as failed
880        let auth_errors = [401, 403];
881        for &status_code in &auth_errors {
882            let error = ProviderError::RequestError {
883                error: format!("Auth error {status_code}"),
884                status_code,
885            };
886            assert!(
887                should_mark_provider_failed(&error),
888                "Status code {status_code} should mark provider as failed"
889            );
890        }
891    }
892
893    #[test]
894    fn test_should_mark_provider_failed_not_found_errors() {
895        // 404 and 410 should mark provider as failed (endpoint issues)
896        let not_found_errors = [404, 410];
897        for &status_code in &not_found_errors {
898            let error = ProviderError::RequestError {
899                error: format!("Not found error {status_code}"),
900                status_code,
901            };
902            assert!(
903                should_mark_provider_failed(&error),
904                "Status code {status_code} should mark provider as failed"
905            );
906        }
907    }
908
909    #[test]
910    fn test_should_mark_provider_failed_client_errors_not_failed() {
911        // These 4xx errors should NOT mark provider as failed (client-side issues)
912        let client_errors = [400, 405, 413, 414, 415, 422, 429];
913        for &status_code in &client_errors {
914            let error = ProviderError::RequestError {
915                error: format!("Client error {status_code}"),
916                status_code,
917            };
918            assert!(
919                !should_mark_provider_failed(&error),
920                "Status code {status_code} should NOT mark provider as failed"
921            );
922        }
923    }
924
925    #[test]
926    fn test_should_mark_provider_failed_other_error_types() {
927        // Test non-RequestError types - these should NOT mark provider as failed
928        let errors = [
929            ProviderError::Timeout,
930            ProviderError::RateLimited,
931            ProviderError::BadGateway,
932            ProviderError::InvalidAddress("test".to_string()),
933            ProviderError::NetworkConfiguration("test".to_string()),
934            ProviderError::Other("test".to_string()),
935        ];
936
937        for error in errors {
938            assert!(
939                !should_mark_provider_failed(&error),
940                "Error type {error:?} should NOT mark provider as failed"
941            );
942        }
943    }
944
945    #[test]
946    fn test_should_mark_provider_failed_edge_cases() {
947        // Test some edge case status codes
948        let edge_cases = [
949            (200, false), // Success - shouldn't happen in error context but test anyway
950            (300, false), // Redirection
951            (418, false), // I'm a teapot - should not mark as failed
952            (451, false), // Unavailable for legal reasons - client issue
953            (499, false), // Client closed request - client issue
954        ];
955
956        for (status_code, should_fail) in edge_cases {
957            let error = ProviderError::RequestError {
958                error: format!("Edge case error {status_code}"),
959                status_code,
960            };
961            assert_eq!(
962                should_mark_provider_failed(&error),
963                should_fail,
964                "Status code {} should {} mark provider as failed",
965                status_code,
966                if should_fail { "" } else { "NOT" }
967            );
968        }
969    }
970
971    #[test]
972    fn test_is_retriable_error_retriable_types() {
973        // These error types should be retriable
974        let retriable_errors = [
975            ProviderError::Timeout,
976            ProviderError::RateLimited,
977            ProviderError::BadGateway,
978            ProviderError::TransportError("test".to_string()),
979        ];
980
981        for error in retriable_errors {
982            assert!(
983                is_retriable_error(&error),
984                "Error type {error:?} should be retriable"
985            );
986        }
987    }
988
989    #[test]
990    fn test_is_retriable_error_non_retriable_types() {
991        // These error types should NOT be retriable
992        let non_retriable_errors = [
993            ProviderError::InvalidAddress("test".to_string()),
994            ProviderError::NetworkConfiguration("test".to_string()),
995            ProviderError::RequestError {
996                error: "Some error".to_string(),
997                status_code: 400,
998            },
999        ];
1000
1001        for error in non_retriable_errors {
1002            assert!(
1003                !is_retriable_error(&error),
1004                "Error type {error:?} should NOT be retriable"
1005            );
1006        }
1007    }
1008
1009    #[test]
1010    fn test_is_retriable_error_message_based_detection() {
1011        // Test errors that should be retriable based on message content
1012        let retriable_messages = [
1013            "Connection timeout occurred",
1014            "Network connection reset",
1015            "Connection refused",
1016            "TIMEOUT error happened",
1017            "Connection was reset by peer",
1018        ];
1019
1020        for message in retriable_messages {
1021            let error = ProviderError::Other(message.to_string());
1022            assert!(
1023                is_retriable_error(&error),
1024                "Error with message '{message}' should be retriable"
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn test_is_retriable_error_message_based_non_retriable() {
1031        // Test errors that should NOT be retriable based on message content
1032        let non_retriable_messages = [
1033            "Invalid address format",
1034            "Bad request parameters",
1035            "Authentication failed",
1036            "Method not found",
1037            "Some other error",
1038        ];
1039
1040        for message in non_retriable_messages {
1041            let error = ProviderError::Other(message.to_string());
1042            assert!(
1043                !is_retriable_error(&error),
1044                "Error with message '{message}' should NOT be retriable"
1045            );
1046        }
1047    }
1048
1049    #[test]
1050    fn test_is_retriable_error_case_insensitive() {
1051        // Test that message-based detection is case insensitive
1052        let case_variations = [
1053            "TIMEOUT",
1054            "Timeout",
1055            "timeout",
1056            "CONNECTION",
1057            "Connection",
1058            "connection",
1059            "RESET",
1060            "Reset",
1061            "reset",
1062        ];
1063
1064        for message in case_variations {
1065            let error = ProviderError::Other(message.to_string());
1066            assert!(
1067                is_retriable_error(&error),
1068                "Error with message '{message}' should be retriable (case insensitive)"
1069            );
1070        }
1071    }
1072
1073    #[test]
1074    fn test_is_retriable_error_request_error_retriable_5xx() {
1075        // Test retriable 5xx status codes
1076        let retriable_5xx = vec![
1077            (500, "Internal Server Error"),
1078            (502, "Bad Gateway"),
1079            (503, "Service Unavailable"),
1080            (504, "Gateway Timeout"),
1081            (506, "Variant Also Negotiates"),
1082            (507, "Insufficient Storage"),
1083            (508, "Loop Detected"),
1084            (510, "Not Extended"),
1085            (511, "Network Authentication Required"),
1086            (599, "Network Connect Timeout Error"),
1087        ];
1088
1089        for (status_code, description) in retriable_5xx {
1090            let error = ProviderError::RequestError {
1091                error: description.to_string(),
1092                status_code,
1093            };
1094            assert!(
1095                is_retriable_error(&error),
1096                "Status code {status_code} ({description}) should be retriable"
1097            );
1098        }
1099    }
1100
1101    #[test]
1102    fn test_is_retriable_error_request_error_non_retriable_5xx() {
1103        // Test non-retriable 5xx status codes (persistent server issues)
1104        let non_retriable_5xx = vec![
1105            (501, "Not Implemented"),
1106            (505, "HTTP Version Not Supported"),
1107        ];
1108
1109        for (status_code, description) in non_retriable_5xx {
1110            let error = ProviderError::RequestError {
1111                error: description.to_string(),
1112                status_code,
1113            };
1114            assert!(
1115                !is_retriable_error(&error),
1116                "Status code {status_code} ({description}) should NOT be retriable"
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn test_is_retriable_error_request_error_retriable_4xx() {
1123        // Test retriable 4xx status codes (timeout/rate-limit related)
1124        let retriable_4xx = vec![
1125            (408, "Request Timeout"),
1126            (425, "Too Early"),
1127            (429, "Too Many Requests"),
1128        ];
1129
1130        for (status_code, description) in retriable_4xx {
1131            let error = ProviderError::RequestError {
1132                error: description.to_string(),
1133                status_code,
1134            };
1135            assert!(
1136                is_retriable_error(&error),
1137                "Status code {status_code} ({description}) should be retriable"
1138            );
1139        }
1140    }
1141
1142    #[test]
1143    fn test_is_retriable_error_request_error_non_retriable_4xx() {
1144        // Test non-retriable 4xx status codes (client errors)
1145        let non_retriable_4xx = vec![
1146            (400, "Bad Request"),
1147            (401, "Unauthorized"),
1148            (403, "Forbidden"),
1149            (404, "Not Found"),
1150            (405, "Method Not Allowed"),
1151            (406, "Not Acceptable"),
1152            (407, "Proxy Authentication Required"),
1153            (409, "Conflict"),
1154            (410, "Gone"),
1155            (411, "Length Required"),
1156            (412, "Precondition Failed"),
1157            (413, "Payload Too Large"),
1158            (414, "URI Too Long"),
1159            (415, "Unsupported Media Type"),
1160            (416, "Range Not Satisfiable"),
1161            (417, "Expectation Failed"),
1162            (418, "I'm a teapot"),
1163            (421, "Misdirected Request"),
1164            (422, "Unprocessable Entity"),
1165            (423, "Locked"),
1166            (424, "Failed Dependency"),
1167            (426, "Upgrade Required"),
1168            (428, "Precondition Required"),
1169            (431, "Request Header Fields Too Large"),
1170            (451, "Unavailable For Legal Reasons"),
1171            (499, "Client Closed Request"),
1172        ];
1173
1174        for (status_code, description) in non_retriable_4xx {
1175            let error = ProviderError::RequestError {
1176                error: description.to_string(),
1177                status_code,
1178            };
1179            assert!(
1180                !is_retriable_error(&error),
1181                "Status code {status_code} ({description}) should NOT be retriable"
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn test_is_retriable_error_request_error_other_status_codes() {
1188        // Test other status codes (1xx, 2xx, 3xx) - should not be retriable
1189        let other_status_codes = vec![
1190            (100, "Continue"),
1191            (101, "Switching Protocols"),
1192            (200, "OK"),
1193            (201, "Created"),
1194            (204, "No Content"),
1195            (300, "Multiple Choices"),
1196            (301, "Moved Permanently"),
1197            (302, "Found"),
1198            (304, "Not Modified"),
1199            (600, "Custom status"),
1200            (999, "Unknown status"),
1201        ];
1202
1203        for (status_code, description) in other_status_codes {
1204            let error = ProviderError::RequestError {
1205                error: description.to_string(),
1206                status_code,
1207            };
1208            assert!(
1209                !is_retriable_error(&error),
1210                "Status code {status_code} ({description}) should NOT be retriable"
1211            );
1212        }
1213    }
1214
1215    #[test]
1216    fn test_is_retriable_error_request_error_boundary_cases() {
1217        // Test boundary cases for our ranges
1218        let test_cases = vec![
1219            // Just before retriable 4xx range
1220            (407, false, "Proxy Authentication Required"),
1221            (408, true, "Request Timeout - first retriable 4xx"),
1222            (409, false, "Conflict"),
1223            // Around 425
1224            (424, false, "Failed Dependency"),
1225            (425, true, "Too Early"),
1226            (426, false, "Upgrade Required"),
1227            // Around 429
1228            (428, false, "Precondition Required"),
1229            (429, true, "Too Many Requests"),
1230            (430, false, "Would be non-retriable if it existed"),
1231            // 5xx boundaries
1232            (499, false, "Last 4xx"),
1233            (500, true, "First 5xx - retriable"),
1234            (501, false, "Not Implemented - exception"),
1235            (502, true, "Bad Gateway - retriable"),
1236            (505, false, "HTTP Version Not Supported - exception"),
1237            (506, true, "First after 505 exception"),
1238            (599, true, "Last defined 5xx"),
1239        ];
1240
1241        for (status_code, should_be_retriable, description) in test_cases {
1242            let error = ProviderError::RequestError {
1243                error: description.to_string(),
1244                status_code,
1245            };
1246            assert_eq!(
1247                is_retriable_error(&error),
1248                should_be_retriable,
1249                "Status code {} ({}) should{} be retriable",
1250                status_code,
1251                description,
1252                if should_be_retriable { "" } else { " NOT" }
1253            );
1254        }
1255    }
1256}