Skip to main content

rithmic_rs/
ws.rs

1use futures_util::{Sink, SinkExt};
2use std::time::Duration;
3use tracing::{info, warn};
4
5use tokio::{
6    net::TcpStream,
7    time::{Instant, Interval, interval_at, sleep, timeout},
8};
9
10use tokio_tungstenite::{
11    MaybeTlsStream, WebSocketStream, connect_async_with_config,
12    tungstenite::{Error, Message},
13};
14
15/// Number of seconds between heartbeats sent to the server when the login
16/// response carries no interval of its own.
17pub(crate) const HEARTBEAT_SECS: u64 = 60;
18
19/// Number of seconds between WebSocket ping frames sent to detect dead connections.
20pub(crate) const PING_INTERVAL_SECS: u64 = 60;
21
22/// Timeout in seconds for WebSocket pong response.
23pub(crate) const PING_TIMEOUT_SECS: u64 = 50;
24
25/// Timeout in seconds for any actor-owned WebSocket write.
26pub(crate) const SEND_TIMEOUT_SECS: u64 = 10;
27
28/// Connection attempt timeout in seconds.
29const CONNECT_TIMEOUT_SECS: u64 = 2;
30
31/// Base backoff in milliseconds multiplied by the attempt number.
32const BACKOFF_MS_BASE: u64 = 500;
33
34/// Maximum backoff duration in seconds (rate limit for connection attempts).
35const MAX_BACKOFF_SECS: u64 = 60;
36
37/// Connection strategy for connecting to Rithmic servers.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum ConnectStrategy {
41    /// Single connection attempt. Fast-fail, no retries.
42    Simple,
43    /// Retry same URL indefinitely with linear backoff (500 ms more per attempt, capped at 60s, jittered ±50%). Recommended for most users.
44    Retry,
45    /// Alternates between primary and beta URLs indefinitely. Useful when main server has issues.
46    AlternateWithRetry,
47}
48
49/// Error returned when a bounded WebSocket send does not complete.
50#[derive(Debug)]
51pub(crate) enum WebSocketSendError {
52    /// The underlying sink returned an error before the timeout elapsed.
53    Transport(Error),
54    /// The send future did not complete within the configured timeout.
55    Timeout,
56}
57
58/// Sends a WebSocket message with a hard timeout.
59///
60/// This prevents actor loop branches from hanging indefinitely on half-open
61/// connections where the TCP write side no longer makes progress.
62///
63/// # Cancellation safety
64///
65/// This function is not cancel-safe with respect to the underlying sink. If the
66/// timeout fires while the sink is flushing, the message may already be buffered
67/// inside the WebSocket stream even though the future returned `Timeout`.
68/// Callers must treat the sink as poisoned after any non-`Ok` return and avoid
69/// reusing it.
70pub(crate) async fn send_with_timeout<S>(
71    sink: &mut S,
72    msg: Message,
73    timeout_duration: Duration,
74) -> Result<(), WebSocketSendError>
75where
76    S: Sink<Message, Error = Error> + Unpin,
77{
78    match timeout(timeout_duration, sink.send(msg)).await {
79        Ok(Ok(())) => Ok(()),
80        Ok(Err(error)) => Err(WebSocketSendError::Transport(error)),
81        Err(_) => Err(WebSocketSendError::Timeout),
82    }
83}
84
85/// Creates an interval for sending heartbeats.
86///
87/// `override_secs` is the period the server asked for in its login response.
88/// A period of 0 falls back to [`HEARTBEAT_SECS`], since `interval_at` panics
89/// on a zero period.
90pub(crate) fn get_heartbeat_interval(override_secs: Option<u64>) -> Interval {
91    let secs = override_secs
92        .filter(|secs| *secs > 0)
93        .unwrap_or(HEARTBEAT_SECS);
94    let heartbeat_interval = Duration::from_secs(secs);
95    let start_offset = Instant::now() + heartbeat_interval;
96
97    interval_at(start_offset, heartbeat_interval)
98}
99
100/// Creates an interval for sending WebSocket pings.
101///
102/// Returns an interval starting after the first ping period elapses.
103pub(crate) fn get_ping_interval() -> Interval {
104    let ping_interval = Duration::from_secs(PING_INTERVAL_SECS);
105    let start_offset = Instant::now() + ping_interval;
106
107    interval_at(start_offset, ping_interval)
108}
109
110/// Connect to a single URL without retry.
111///
112/// Bounded by [`CONNECT_TIMEOUT_SECS`] so `Simple` fast-fails instead of
113/// hanging for the OS TCP timeout.
114///
115/// # Arguments
116/// * `url` - WebSocket URL to connect to
117///
118/// # Returns
119/// WebSocketStream on success, error on failure or timeout.
120async fn connect(url: &str) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, Error> {
121    info!("Connecting to {}", url);
122
123    let (ws_stream, _) = timeout(
124        Duration::from_secs(CONNECT_TIMEOUT_SECS),
125        connect_async_with_config(url, None, true),
126    )
127    .await
128    .map_err(|_| {
129        Error::Io(std::io::Error::new(
130            std::io::ErrorKind::TimedOut,
131            "connection attempt timed out",
132        ))
133    })??;
134
135    info!("Successfully connected to {}", url);
136
137    Ok(ws_stream)
138}
139
140/// Scale a delay by a factor in [0.5, 1.5), seeded from the clock's
141/// sub-second nanos — enough spread to break reconnect lockstep without
142/// pulling in a rand dependency.
143fn jittered(ms: u64) -> u64 {
144    let nanos = std::time::SystemTime::now()
145        .duration_since(std::time::UNIX_EPOCH)
146        .map(|d| u64::from(d.subsec_nanos()))
147        .unwrap_or(512);
148
149    ms / 2 + ms * (nanos % 1024) / 1024
150}
151
152/// Connect with indefinite retry and linear backoff — 500 ms more per
153/// attempt, capped at [`MAX_BACKOFF_SECS`] and then jittered by ±50%, so
154/// the spread survives a long outage (delays range 30–90 s at the cap).
155///
156/// The jitter keeps plants that lost the same connection from retrying in
157/// lockstep against a recovering server.
158///
159/// `urls` is cycled by attempt number: pass one URL to retry it, or
160/// primary + beta to alternate between them. Never returns until a
161/// connection succeeds.
162async fn connect_with_retry(urls: &[&str]) -> WebSocketStream<MaybeTlsStream<TcpStream>> {
163    let mut attempt: u64 = 1;
164
165    loop {
166        let url = urls[(attempt - 1) as usize % urls.len()];
167
168        info!("Attempt {}: connecting to {}", attempt, url);
169
170        match timeout(
171            Duration::from_secs(CONNECT_TIMEOUT_SECS),
172            connect_async_with_config(url, None, true),
173        )
174        .await
175        {
176            Ok(Ok((ws_stream, _))) => {
177                info!("Successfully connected to {}", url);
178                return ws_stream;
179            }
180            Ok(Err(e)) => warn!("connect_async failed for {}: {:?}", url, e),
181            Err(e) => warn!("connect_async to {} timed out: {:?}", url, e),
182        }
183
184        let backoff_ms = BACKOFF_MS_BASE
185            .saturating_mul(attempt)
186            .min(MAX_BACKOFF_SECS * 1000);
187        let backoff_duration = Duration::from_millis(jittered(backoff_ms));
188
189        info!("Backing off for {:?} before retry", backoff_duration);
190
191        sleep(backoff_duration).await;
192        attempt += 1;
193    }
194}
195
196/// Connect using the specified strategy.
197///
198/// # Arguments
199/// * `primary_url` - Primary WebSocket URL
200/// * `beta_url` - Beta WebSocket URL (only used for AlternateWithRetry)
201/// * `strategy` - Connection strategy to use
202///
203/// # Returns
204/// WebSocketStream on success; an error only for `Simple`, since the retry
205/// strategies keep trying until they connect.
206pub(crate) async fn connect_with_strategy(
207    primary_url: &str,
208    beta_url: &str,
209    strategy: ConnectStrategy,
210) -> Result<WebSocketStream<MaybeTlsStream<TcpStream>>, Error> {
211    match strategy {
212        ConnectStrategy::Simple => connect(primary_url).await,
213        ConnectStrategy::Retry => Ok(connect_with_retry(&[primary_url]).await),
214        ConnectStrategy::AlternateWithRetry => {
215            Ok(connect_with_retry(&[primary_url, beta_url]).await)
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::{
223        pin::Pin,
224        task::{Context, Poll},
225    };
226
227    use super::*;
228
229    enum MockSinkBehavior {
230        Ready,
231        Error,
232        Pending,
233    }
234
235    struct MockMessageSink {
236        behavior: MockSinkBehavior,
237        sent_messages: Vec<Message>,
238    }
239
240    impl MockMessageSink {
241        fn ready() -> Self {
242            Self {
243                behavior: MockSinkBehavior::Ready,
244                sent_messages: Vec::new(),
245            }
246        }
247
248        fn error() -> Self {
249            Self {
250                behavior: MockSinkBehavior::Error,
251                sent_messages: Vec::new(),
252            }
253        }
254
255        fn pending() -> Self {
256            Self {
257                behavior: MockSinkBehavior::Pending,
258                sent_messages: Vec::new(),
259            }
260        }
261    }
262
263    impl Sink<Message> for MockMessageSink {
264        type Error = Error;
265
266        fn poll_ready(
267            self: Pin<&mut Self>,
268            _cx: &mut Context<'_>,
269        ) -> Poll<Result<(), Self::Error>> {
270            match self.behavior {
271                MockSinkBehavior::Ready => Poll::Ready(Ok(())),
272                MockSinkBehavior::Error => Poll::Ready(Err(Error::ConnectionClosed)),
273                MockSinkBehavior::Pending => Poll::Pending,
274            }
275        }
276
277        fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
278            self.get_mut().sent_messages.push(item);
279            Ok(())
280        }
281
282        fn poll_flush(
283            self: Pin<&mut Self>,
284            _cx: &mut Context<'_>,
285        ) -> Poll<Result<(), Self::Error>> {
286            match self.behavior {
287                MockSinkBehavior::Ready => Poll::Ready(Ok(())),
288                MockSinkBehavior::Error => Poll::Ready(Err(Error::ConnectionClosed)),
289                MockSinkBehavior::Pending => Poll::Pending,
290            }
291        }
292
293        fn poll_close(
294            self: Pin<&mut Self>,
295            _cx: &mut Context<'_>,
296        ) -> Poll<Result<(), Self::Error>> {
297            match self.behavior {
298                MockSinkBehavior::Ready => Poll::Ready(Ok(())),
299                MockSinkBehavior::Error => Poll::Ready(Err(Error::ConnectionClosed)),
300                MockSinkBehavior::Pending => Poll::Pending,
301            }
302        }
303    }
304
305    #[tokio::test]
306    async fn send_with_timeout_succeeds_for_ready_sink() {
307        let mut sink = MockMessageSink::ready();
308
309        let result = send_with_timeout(
310            &mut sink,
311            Message::Ping(Vec::new().into()),
312            Duration::from_millis(10),
313        )
314        .await;
315
316        assert!(result.is_ok());
317        assert_eq!(sink.sent_messages.len(), 1);
318    }
319
320    #[tokio::test]
321    async fn send_with_timeout_returns_transport_error() {
322        let mut sink = MockMessageSink::error();
323
324        let result = send_with_timeout(
325            &mut sink,
326            Message::Ping(Vec::new().into()),
327            Duration::from_millis(10),
328        )
329        .await;
330
331        assert!(matches!(
332            result,
333            Err(WebSocketSendError::Transport(Error::ConnectionClosed))
334        ));
335    }
336
337    #[tokio::test]
338    async fn send_with_timeout_returns_timeout_for_stuck_sink() {
339        let mut sink = MockMessageSink::pending();
340
341        let result = send_with_timeout(
342            &mut sink,
343            Message::Ping(Vec::new().into()),
344            Duration::from_millis(10),
345        )
346        .await;
347
348        assert!(matches!(result, Err(WebSocketSendError::Timeout)));
349    }
350
351    #[tokio::test]
352    async fn get_heartbeat_interval_uses_the_server_period() {
353        assert_eq!(
354            get_heartbeat_interval(Some(30)).period(),
355            Duration::from_secs(30)
356        );
357        assert_eq!(
358            get_heartbeat_interval(Some(120)).period(),
359            Duration::from_secs(120)
360        );
361    }
362
363    #[tokio::test]
364    async fn get_heartbeat_interval_falls_back_to_the_default() {
365        let default = Duration::from_secs(HEARTBEAT_SECS);
366
367        assert_eq!(get_heartbeat_interval(None).period(), default);
368        assert_eq!(get_heartbeat_interval(Some(0)).period(), default);
369    }
370}