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