unifly_api/websocket/
runtime.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use futures_util::StreamExt;
5use tokio::sync::broadcast;
6use tokio_tungstenite::Connector;
7use tokio_tungstenite::tungstenite::{self, ClientRequestBuilder};
8use tokio_util::sync::CancellationToken;
9use url::Url;
10
11use crate::error::Error;
12use crate::transport::TlsMode;
13
14use super::parser::{UnifiEvent, parse_and_broadcast};
15use super::tls::build_tls_connector;
16
17const EVENT_CHANNEL_CAPACITY: usize = 1024;
18
19#[derive(Debug, Clone)]
21pub struct ReconnectConfig {
22 pub initial_delay: Duration,
24
25 pub max_delay: Duration,
27
28 pub max_retries: Option<u32>,
31}
32
33impl Default for ReconnectConfig {
34 fn default() -> Self {
35 Self {
36 initial_delay: Duration::from_secs(1),
37 max_delay: Duration::from_secs(30),
38 max_retries: None,
39 }
40 }
41}
42
43pub struct WebSocketHandle {
48 event_rx: broadcast::Receiver<Arc<UnifiEvent>>,
49 cancel: CancellationToken,
50}
51
52impl WebSocketHandle {
53 pub fn connect(
59 ws_url: Url,
60 reconnect: ReconnectConfig,
61 cancel: CancellationToken,
62 cookie: Option<String>,
63 tls_mode: TlsMode,
64 ) -> Result<Self, Error> {
65 let (event_tx, event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
66
67 let task_cancel = cancel.clone();
68 tokio::spawn(async move {
69 ws_loop(ws_url, event_tx, reconnect, task_cancel, cookie, tls_mode).await;
70 });
71
72 Ok(Self { event_rx, cancel })
73 }
74
75 pub fn subscribe(&self) -> broadcast::Receiver<Arc<UnifiEvent>> {
80 self.event_rx.resubscribe()
81 }
82
83 pub fn shutdown(&self) {
85 self.cancel.cancel();
86 }
87}
88
89async fn ws_loop(
90 ws_url: Url,
91 event_tx: broadcast::Sender<Arc<UnifiEvent>>,
92 reconnect: ReconnectConfig,
93 cancel: CancellationToken,
94 cookie: Option<String>,
95 tls_mode: TlsMode,
96) {
97 let mut attempt: u32 = 0;
98
99 loop {
100 tokio::select! {
101 biased;
102 () = cancel.cancelled() => break,
103 result = connect_and_read(&ws_url, &event_tx, &cancel, cookie.as_deref(), &tls_mode) => {
104 match result {
105 Ok(()) => {
106 tracing::info!("WebSocket disconnected cleanly, reconnecting");
107 attempt = 0;
108 }
109 Err(error) => {
110 tracing::warn!(error = %error, attempt, "WebSocket error");
111
112 if let Some(max) = reconnect.max_retries
113 && attempt >= max {
114 tracing::error!(
115 max_retries = max,
116 "WebSocket reconnection limit reached, giving up"
117 );
118 break;
119 }
120
121 let delay = calculate_backoff(attempt, &reconnect);
122 let delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX);
123 tracing::info!(delay_ms, attempt, "Waiting before reconnect");
124
125 tokio::select! {
126 biased;
127 () = cancel.cancelled() => break,
128 () = tokio::time::sleep(delay) => {}
129 }
130
131 attempt += 1;
132 }
133 }
134 }
135 }
136 }
137
138 #[allow(unreachable_code)]
139 {
140 tracing::debug!("WebSocket loop exiting");
141 }
142}
143
144async fn connect_and_read(
145 url: &Url,
146 event_tx: &broadcast::Sender<Arc<UnifiEvent>>,
147 cancel: &CancellationToken,
148 cookie: Option<&str>,
149 tls_mode: &TlsMode,
150) -> Result<(), Error> {
151 tracing::info!(url = %url, "Connecting to WebSocket");
152
153 let uri: tungstenite::http::Uri =
154 url.as_str()
155 .parse()
156 .map_err(|error: tungstenite::http::uri::InvalidUri| {
157 Error::WebSocketConnect(error.to_string())
158 })?;
159
160 let mut request = ClientRequestBuilder::new(uri);
161 if let Some(cookie_val) = cookie {
162 request = request.with_header("Cookie", cookie_val);
163 }
164
165 let connector = if url.scheme() == "wss" {
166 build_tls_connector(tls_mode)?
167 } else {
168 Some(Connector::Plain)
169 };
170
171 let (ws_stream, _response) =
172 tokio_tungstenite::connect_async_tls_with_config(request, None, false, connector)
173 .await
174 .map_err(|error| Error::WebSocketConnect(error.to_string()))?;
175
176 tracing::info!("WebSocket connected");
177
178 let (_write, mut read) = ws_stream.split();
179
180 loop {
181 tokio::select! {
182 biased;
183 () = cancel.cancelled() => return Ok(()),
184 frame = read.next() => {
185 match frame {
186 Some(Ok(tungstenite::Message::Text(text))) => {
187 parse_and_broadcast(&text, event_tx);
188 }
189 Some(Ok(tungstenite::Message::Ping(_))) => {
190 tracing::trace!("WebSocket ping");
191 }
192 Some(Ok(tungstenite::Message::Close(frame))) => {
193 if let Some(ref cf) = frame {
194 tracing::info!(
195 code = %cf.code,
196 reason = %cf.reason,
197 "WebSocket close frame received"
198 );
199 } else {
200 tracing::info!("WebSocket close frame received (no payload)");
201 }
202 return Ok(());
203 }
204 Some(Err(error)) => {
205 return Err(Error::WebSocketConnect(error.to_string()));
206 }
207 None => {
208 tracing::info!("WebSocket stream ended");
209 return Ok(());
210 }
211 _ => {}
212 }
213 }
214 }
215 }
216}
217
218fn calculate_backoff(attempt: u32, config: &ReconnectConfig) -> Duration {
219 let base = config.initial_delay.as_secs_f64()
220 * 2.0_f64.powi(i32::try_from(attempt).unwrap_or(i32::MAX));
221 let capped = base.min(config.max_delay.as_secs_f64());
222
223 let jitter_factor = 1.0 + 0.25 * ((f64::from(attempt) * 7.3).sin());
224 let with_jitter = (capped * jitter_factor).max(0.0);
225
226 Duration::from_secs_f64(with_jitter)
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn default_reconnect_config() {
236 let config = ReconnectConfig::default();
237 assert_eq!(config.initial_delay, Duration::from_secs(1));
238 assert_eq!(config.max_delay, Duration::from_secs(30));
239 assert!(config.max_retries.is_none());
240 }
241
242 #[test]
243 fn backoff_increases_exponentially() {
244 let config = ReconnectConfig::default();
245
246 let d0 = calculate_backoff(0, &config);
247 let d1 = calculate_backoff(1, &config);
248 let d2 = calculate_backoff(2, &config);
249
250 assert!(d1 > d0, "d1 ({d1:?}) should be greater than d0 ({d0:?})");
251 assert!(d2 > d1, "d2 ({d2:?}) should be greater than d1 ({d1:?})");
252 }
253
254 #[test]
255 fn backoff_caps_at_max_delay() {
256 let config = ReconnectConfig {
257 initial_delay: Duration::from_secs(1),
258 max_delay: Duration::from_secs(10),
259 max_retries: None,
260 };
261
262 let d10 = calculate_backoff(10, &config);
263 assert!(
264 d10 <= Duration::from_secs(13),
265 "delay at attempt 10 ({d10:?}) should be capped near max_delay"
266 );
267 }
268}