Skip to main content

electrum_client_netagnostic/
client.rs

1//! Electrum Client
2
3use std::sync::RwLock;
4
5use log::{info, warn};
6
7use crate::api::ElectrumApi;
8use crate::batch::Batch;
9use crate::config::Config;
10use crate::raw_client::*;
11use crate::types::*;
12use std::convert::TryFrom;
13
14/// Generalized Electrum client that supports multiple backends. This wraps
15/// [`RawClient`](client/struct.RawClient.html) and provides a more user-friendly
16/// constructor that can choose the right backend based on the url prefix.
17///
18/// **This is available only with the `default` features, or if `proxy` and one ssl implementation are enabled**
19pub enum ClientType {
20    #[allow(missing_docs)]
21    TCP(RawClient<ElectrumPlaintextStream>),
22    #[allow(missing_docs)]
23    SSL(RawClient<ElectrumSslStream>),
24    #[allow(missing_docs)]
25    Socks5(RawClient<ElectrumProxyStream>),
26    #[cfg(feature = "use-websocket")]
27    #[allow(missing_docs)]
28    WS(RawClient<ElectrumWsStream>),
29    #[cfg(all(
30        feature = "use-websocket",
31        any(feature = "use-rustls", feature = "use-rustls-ring"),
32        not(feature = "use-openssl")
33    ))]
34    #[allow(missing_docs)]
35    WSS(RawClient<ElectrumWssStream>),
36}
37
38/// Generalized Electrum client that supports multiple backends. Can re-instantiate client_type if connections
39/// drops
40pub struct Client {
41    client_type: RwLock<ClientType>,
42    config: Config,
43    url: String,
44}
45
46macro_rules! impl_inner_call {
47    ( $self:expr, $name:ident $(, $args:expr)* ) => {
48    {
49        let mut errors = vec![];
50        loop {
51            let read_client = $self.client_type.read().unwrap();
52            let res = match &*read_client {
53                ClientType::TCP(inner) => inner.$name( $($args, )* ),
54                ClientType::SSL(inner) => inner.$name( $($args, )* ),
55                ClientType::Socks5(inner) => inner.$name( $($args, )* ),
56                #[cfg(feature = "use-websocket")]
57                ClientType::WS(inner) => inner.$name( $($args, )* ),
58                #[cfg(all(
59                    feature = "use-websocket",
60                    any(feature = "use-rustls", feature = "use-rustls-ring"),
61                    not(feature = "use-openssl")
62                ))]
63                ClientType::WSS(inner) => inner.$name( $($args, )* ),
64            };
65            drop(read_client);
66            match res {
67                Ok(val) => return Ok(val),
68                Err(Error::Protocol(_)) => {
69                    return res;
70                },
71                Err(e) => {
72                    let failed_attempts = errors.len() + 1;
73
74                    if retries_exhausted(failed_attempts, $self.config.retry()) {
75                        warn!("call '{}' failed after {} attempts", stringify!($name), failed_attempts);
76                        return Err(Error::AllAttemptsErrored(errors));
77                    }
78
79                    warn!("call '{}' failed with {}, retry: {}/{}", stringify!($name), e, failed_attempts, $self.config.retry());
80
81                    errors.push(e);
82
83                    // Only one thread will try to recreate the client getting the write lock,
84                    // other eventual threads will get Err and will block at the beginning of
85                    // previous loop when trying to read()
86                    if let Ok(mut write_client) = $self.client_type.try_write() {
87                        loop {
88                            std::thread::sleep(std::time::Duration::from_secs((1 << errors.len()).min(30) as u64));
89                            match ClientType::from_config(&$self.url, &$self.config) {
90                                Ok(new_client) => {
91                                    info!("Succesfully created new client");
92                                    *write_client = new_client;
93                                    break;
94                                },
95                                Err(e) => {
96                                    let failed_attempts = errors.len() + 1;
97
98                                    if retries_exhausted(failed_attempts, $self.config.retry()) {
99                                        warn!("re-creating client failed after {} attempts", failed_attempts);
100                                        return Err(Error::AllAttemptsErrored(errors));
101                                    }
102
103                                    warn!("re-creating client failed with {}, retry: {}/{}", e, failed_attempts, $self.config.retry());
104
105                                    errors.push(e);
106                                }
107                            }
108                        }
109                    }
110                },
111            }
112        }}
113    }
114}
115
116fn retries_exhausted(failed_attempts: usize, configured_retries: u8) -> bool {
117    match u8::try_from(failed_attempts) {
118        Ok(failed_attempts) => failed_attempts > configured_retries,
119        Err(_) => true, // if the usize doesn't fit into a u8, we definitely exhausted our retries
120    }
121}
122
123impl ClientType {
124    /// Constructor that supports multiple backends and allows configuration through
125    /// the [Config]
126    pub fn from_config(url: &str, config: &Config) -> Result<Self, Error> {
127        if url.starts_with("ssl://") {
128            let url = url.replacen("ssl://", "", 1);
129            let client = match config.socks5() {
130                Some(socks5) => RawClient::new_proxy_ssl(
131                    url.as_str(),
132                    config.validate_domain(),
133                    socks5,
134                    config.timeout(),
135                )?,
136                None => {
137                    RawClient::new_ssl(url.as_str(), config.validate_domain(), config.timeout())?
138                }
139            };
140
141            Ok(ClientType::SSL(client))
142        } else if url.starts_with("wss://") {
143            #[cfg(all(
144                feature = "use-websocket",
145                any(feature = "use-rustls", feature = "use-rustls-ring"),
146                not(feature = "use-openssl")
147            ))]
148            {
149                let url = url.replacen("wss://", "", 1);
150                let client = RawClient::new_wss(
151                    url.as_str(),
152                    config.validate_domain(),
153                    config.timeout(),
154                    config.max_message_size(),
155                )?;
156                Ok(ClientType::WSS(client))
157            }
158            #[cfg(not(all(
159                feature = "use-websocket",
160                any(feature = "use-rustls", feature = "use-rustls-ring"),
161                not(feature = "use-openssl")
162            )))]
163            {
164                Err(Error::Message(
165                    "WSS support requires the 'use-websocket' feature and a rustls feature"
166                        .to_string(),
167                ))
168            }
169        } else if url.starts_with("ws://") {
170            #[cfg(feature = "use-websocket")]
171            {
172                let url = url.replacen("ws://", "", 1);
173                let client =
174                    RawClient::new_ws(url.as_str(), config.timeout(), config.max_message_size())?;
175                Ok(ClientType::WS(client))
176            }
177            #[cfg(not(feature = "use-websocket"))]
178            {
179                Err(Error::Message(
180                    "WebSocket support requires the 'use-websocket' feature".to_string(),
181                ))
182            }
183        } else {
184            let url = url.replacen("tcp://", "", 1);
185
186            Ok(match config.socks5().as_ref() {
187                None => ClientType::TCP(RawClient::new(url.as_str(), config.timeout())?),
188                Some(socks5) => ClientType::Socks5(RawClient::new_proxy(
189                    url.as_str(),
190                    socks5,
191                    config.timeout(),
192                )?),
193            })
194        }
195    }
196}
197
198impl Client {
199    /// Default constructor supporting multiple backends by providing a prefix
200    ///
201    /// Supported prefixes are:
202    /// - tcp:// for a TCP plaintext client.
203    /// - ssl:// for an SSL-encrypted client. The server certificate will be verified.
204    /// - ws:// for a WebSocket client (requires `use-websocket` feature).
205    /// - wss:// for a secure WebSocket client (requires `use-websocket` and a rustls feature).
206    ///
207    /// If no prefix is specified, then `tcp://` is assumed.
208    ///
209    /// See [Client::from_config] for more configuration options
210    ///
211    pub fn new(url: &str) -> Result<Self, Error> {
212        Self::from_config(url, Config::default())
213    }
214
215    /// Generic constructor that supports multiple backends and allows configuration through
216    /// the [Config]
217    pub fn from_config(url: &str, config: Config) -> Result<Self, Error> {
218        let client_type = RwLock::new(ClientType::from_config(url, &config)?);
219
220        Ok(Client {
221            client_type,
222            config,
223            url: url.to_string(),
224        })
225    }
226}
227
228impl ElectrumApi for Client {
229    #[inline]
230    fn raw_call(
231        &self,
232        method_name: &str,
233        params: impl IntoIterator<Item = Param>,
234    ) -> Result<serde_json::Value, Error> {
235        // We can't passthrough this method to the inner client because it would require the
236        // `params` argument to also be `Copy` (because it's used multiple times for multiple
237        // retries). To avoid adding this extra trait bound we instead re-direct this call to the internal
238        // `RawClient::internal_raw_call_with_vec` method.
239
240        let vec = params.into_iter().collect::<Vec<Param>>();
241        impl_inner_call!(self, internal_raw_call_with_vec, method_name, vec.clone());
242    }
243
244    #[inline]
245    fn batch_call(&self, batch: &Batch) -> Result<Vec<serde_json::Value>, Error> {
246        impl_inner_call!(self, batch_call, batch)
247    }
248
249    #[inline]
250    fn ping(&self) -> Result<(), Error> {
251        impl_inner_call!(self, ping)
252    }
253
254    #[inline]
255    #[cfg(feature = "debug-calls")]
256    fn calls_made(&self) -> Result<usize, Error> {
257        impl_inner_call!(self, calls_made)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn more_failed_attempts_than_retries_means_exhausted() {
267        let exhausted = retries_exhausted(10, 5);
268
269        assert_eq!(exhausted, true)
270    }
271
272    #[test]
273    fn failed_attempts_bigger_than_u8_means_exhausted() {
274        let failed_attempts = u8::MAX as usize + 1;
275
276        let exhausted = retries_exhausted(failed_attempts, u8::MAX);
277
278        assert_eq!(exhausted, true)
279    }
280
281    #[test]
282    fn less_failed_attempts_means_not_exhausted() {
283        let exhausted = retries_exhausted(2, 5);
284
285        assert_eq!(exhausted, false)
286    }
287
288    #[test]
289    fn attempts_equals_retries_means_not_exhausted_yet() {
290        let exhausted = retries_exhausted(2, 2);
291
292        assert_eq!(exhausted, false)
293    }
294
295    #[test]
296    #[ignore]
297    fn test_local_timeout() {
298        // This test assumes a couple things:
299        // - that `localhost` is resolved to two IP addresses, `127.0.0.1` and `::1` (with the v6
300        //   one having higher priority)
301        // - that the system silently drops packets to `[::1]:60000` or a different port if
302        //   specified through `TEST_ELECTRUM_TIMEOUT_PORT`
303        //
304        //   this can be setup with: ip6tables -I INPUT 1 -p tcp -d ::1 --dport 60000 -j DROP
305        //   and removed with:       ip6tables -D INPUT -p tcp -d ::1 --dport 60000 -j DROP
306        //
307        // The test tries to create a client to `localhost` and expects it to succeed, but only
308        // after at least 2 seconds have passed which is roughly the timeout time for the first
309        // try.
310
311        use std::net::TcpListener;
312        use std::sync::mpsc::channel;
313        use std::time::{Duration, Instant};
314
315        let endpoint =
316            std::env::var("TEST_ELECTRUM_TIMEOUT_PORT").unwrap_or("localhost:60000".into());
317        let (sender, receiver) = channel();
318
319        std::thread::spawn(move || {
320            let listener = TcpListener::bind("127.0.0.1:60000").unwrap();
321            sender.send(()).unwrap();
322
323            for _stream in listener.incoming() {
324                loop {}
325            }
326        });
327
328        receiver
329            .recv_timeout(Duration::from_secs(5))
330            .expect("Can't start local listener");
331
332        let now = Instant::now();
333        let client = Client::from_config(
334            &endpoint,
335            crate::config::ConfigBuilder::new()
336                .timeout(Some(Duration::from_secs(5)))
337                .build(),
338        );
339        let elapsed = now.elapsed();
340
341        assert!(client.is_ok());
342        assert!(elapsed > Duration::from_secs(2));
343    }
344}