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
15pub(crate) const HEARTBEAT_SECS: u64 = 60;
18
19pub(crate) const PING_INTERVAL_SECS: u64 = 60;
21
22pub(crate) const PING_TIMEOUT_SECS: u64 = 50;
24
25pub(crate) const SEND_TIMEOUT_SECS: u64 = 10;
27
28const CONNECT_TIMEOUT_SECS: u64 = 2;
30
31const BACKOFF_MS_BASE: u64 = 500;
33
34const MAX_BACKOFF_SECS: u64 = 60;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum ConnectStrategy {
41 Simple,
43 Retry,
45 AlternateWithRetry,
47}
48
49#[derive(Debug)]
51pub(crate) enum WebSocketSendError {
52 Transport(Error),
54 Timeout,
56}
57
58pub(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
85pub(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
100pub(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
110async 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
140fn 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
152async 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
196pub(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}