1use std::collections::{HashSet, VecDeque};
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Duration;
6
7use futures_util::stream::SplitSink;
8use futures_util::{SinkExt, StreamExt};
9use serde::Deserialize;
10use serde::de::DeserializeOwned;
11use serde_json::{Map, Value, json};
12use tokio::net::TcpStream;
13use tokio::sync::mpsc;
14use tokio::task::JoinHandle;
15use tokio::time::{Instant, interval_at, sleep, timeout};
16use tokio_tungstenite::tungstenite::client::IntoClientRequest;
17use tokio_tungstenite::tungstenite::http::HeaderValue;
18use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
19use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
20use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
21use tokio_tungstenite::tungstenite::{Error as WsError, Message};
22use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async_with_config};
23
24use crate::config::Config;
25use crate::error::Error;
26use crate::models::{Article, RawArticle};
27use crate::params::{GetArticlesWebSocketParams, GetRawArticlesWebSocketParams};
28use crate::version::CLIENT_VERSION;
29
30pub type OnClose = Arc<dyn Fn(u16, &str) + Send + Sync>;
32
33#[derive(Clone)]
35pub struct WebSocketOptions {
36 pub ping_interval: Duration,
38 pub pong_timeout: Duration,
40 pub base_reconnect_delay: Duration,
42 pub max_reconnect_delay: Duration,
44 pub connection_lifetime: Duration,
46 pub takeover: bool,
48 pub on_close: Option<OnClose>,
50}
51
52impl Default for WebSocketOptions {
53 fn default() -> Self {
54 Self {
55 ping_interval: Duration::from_secs(25),
56 pong_timeout: Duration::from_secs(60),
57 base_reconnect_delay: Duration::from_millis(500),
58 max_reconnect_delay: Duration::from_secs(10),
59 connection_lifetime: Duration::from_secs(115 * 60),
60 takeover: false,
61 on_close: None,
62 }
63 }
64}
65
66impl std::fmt::Debug for WebSocketOptions {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("WebSocketOptions")
69 .field("ping_interval", &self.ping_interval)
70 .field("pong_timeout", &self.pong_timeout)
71 .field("base_reconnect_delay", &self.base_reconnect_delay)
72 .field("max_reconnect_delay", &self.max_reconnect_delay)
73 .field("connection_lifetime", &self.connection_lifetime)
74 .field("takeover", &self.takeover)
75 .field("on_close", &self.on_close.as_ref().map(|_| "Fn"))
76 .finish()
77 }
78}
79
80const CLOSE_PROACTIVE_ROTATION: u16 = 4000;
82const CLOSE_RATE_LIMITED: u16 = 4001;
83const CLOSE_USER_BLOCKED: u16 = 4002;
84const CLOSE_ADMIN_KICK: u16 = 4003;
85const CLOSE_POLICY_VIOLATION: u16 = 1008;
87
88const RECENT_ARTICLE_CACHE_SIZE: usize = 10;
89const WATCHDOG_INTERVAL: Duration = Duration::from_secs(5);
90const DIAL_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(60);
91const ERROR_RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(60);
92const ERROR_BLOCKED_BACKOFF: Duration = Duration::from_secs(60 * 60);
93const DEFAULT_ADMIN_KICK_RETRY: Duration = Duration::from_secs(15 * 60);
94const MAX_ARTICLE_MESSAGE_SIZE: usize = 16 << 20;
95
96pub struct WebSocketClient {
99 cfg: Config,
100 opts: WebSocketOptions,
101}
102
103impl WebSocketClient {
104 pub fn new(cfg: Config, opts: WebSocketOptions) -> Self {
107 Self { cfg, opts }
108 }
109
110 pub fn stream(&self, params: GetArticlesWebSocketParams) -> ArticleStream<Article> {
118 spawn_stream(
119 self.cfg.clone(),
120 self.opts.clone(),
121 self.cfg.wss_url.clone(),
122 ¶ms,
123 Some(|a: &Article| a.link.clone()),
124 )
125 }
126}
127
128pub struct RawWebSocketClient {
131 cfg: Config,
132 opts: WebSocketOptions,
133}
134
135impl RawWebSocketClient {
136 pub fn new(cfg: Config, opts: WebSocketOptions) -> Self {
139 Self { cfg, opts }
140 }
141
142 pub fn stream(&self, params: GetRawArticlesWebSocketParams) -> ArticleStream<RawArticle> {
147 spawn_stream(
148 self.cfg.clone(),
149 self.opts.clone(),
150 format!("{}/raw", self.cfg.wss_url),
151 ¶ms,
152 None,
153 )
154 }
155}
156
157pub struct ArticleStream<T> {
160 rx: mpsc::Receiver<Result<T, Error>>,
161 handle: JoinHandle<()>,
162}
163
164impl<T> futures_core::Stream for ArticleStream<T> {
165 type Item = Result<T, Error>;
166
167 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
168 self.get_mut().rx.poll_recv(cx)
169 }
170}
171
172impl<T> Drop for ArticleStream<T> {
173 fn drop(&mut self) {
174 self.handle.abort();
175 }
176}
177
178fn spawn_stream<T>(
179 cfg: Config,
180 opts: WebSocketOptions,
181 url: String,
182 params: &impl serde::Serialize,
183 identify: Option<fn(&T) -> String>,
184) -> ArticleStream<T>
185where
186 T: DeserializeOwned + Send + 'static,
187{
188 let payload = match serde_json::to_value(params) {
189 Ok(Value::Object(map)) => map,
190 _ => Map::new(),
191 };
192 let (tx, rx) = mpsc::channel(256);
193 let handle = tokio::spawn(run_stream(cfg, opts, url, payload, identify, tx));
194 ArticleStream { rx, handle }
195}
196
197struct Dedup {
199 order: VecDeque<String>,
200 seen: HashSet<String>,
201}
202
203impl Dedup {
204 fn new() -> Self {
205 Self {
206 order: VecDeque::new(),
207 seen: HashSet::new(),
208 }
209 }
210
211 fn check_and_track(&mut self, id: String) -> bool {
213 if self.seen.contains(&id) {
214 return true;
215 }
216 self.order.push_back(id.clone());
217 self.seen.insert(id);
218 if self.order.len() > RECENT_ARTICLE_CACHE_SIZE {
219 if let Some(old) = self.order.pop_front() {
220 self.seen.remove(&old);
221 }
222 }
223 false
224 }
225}
226
227enum ConnEnd {
229 Reconnect { connected: bool },
232 Terminal(Option<Error>),
234}
235
236async fn run_stream<T>(
240 cfg: Config,
241 opts: WebSocketOptions,
242 url: String,
243 payload: Map<String, Value>,
244 identify: Option<fn(&T) -> String>,
245 tx: mpsc::Sender<Result<T, Error>>,
246) where
247 T: DeserializeOwned + Send + 'static,
248{
249 let mut delay = opts.base_reconnect_delay;
250 let mut reconnect_at: Option<Instant> = None;
251 let mut dedup = identify.map(|_| Dedup::new());
252
253 loop {
254 if tx.is_closed() {
255 return;
256 }
257 tracing::info!(url = %url, "finlight ws: connecting");
258 let end = run_connection(
259 &cfg,
260 &opts,
261 &url,
262 &payload,
263 identify,
264 dedup.as_mut(),
265 &mut reconnect_at,
266 &tx,
267 )
268 .await;
269 let connected = match end {
270 ConnEnd::Terminal(Some(err)) => {
271 let _ = tx.send(Err(err)).await;
272 return;
273 }
274 ConnEnd::Terminal(None) => return,
275 ConnEnd::Reconnect { connected } => connected,
276 };
277 if connected {
278 delay = opts.base_reconnect_delay;
279 }
280
281 let now = Instant::now();
282 let wait = match reconnect_at {
283 Some(at) if at > now => {
284 let wait = at - now;
285 tracing::info!(?wait, "finlight ws: waiting until reconnect_at");
286 wait
287 }
288 _ => {
289 let wait = delay;
290 tracing::info!(delay = ?wait, "finlight ws: reconnecting");
291 delay = (delay * 2).min(opts.max_reconnect_delay);
292 wait
293 }
294 };
295 tokio::select! {
296 _ = sleep(wait) => {}
297 _ = tx.closed() => return,
298 }
299 }
300}
301
302type WsSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
303
304#[derive(Deserialize)]
306struct WsMessage {
307 #[serde(default)]
308 action: String,
309 #[serde(default)]
310 t: Option<i64>,
311 #[serde(default, rename = "leaseId")]
312 lease_id: Option<String>,
313 #[serde(default, rename = "clientNonce")]
314 client_nonce: Option<String>,
315 #[serde(default)]
316 reason: Option<String>,
317 #[serde(default, rename = "newLeaseId")]
318 new_lease_id: Option<String>,
319 #[serde(default, rename = "retryAfter")]
321 retry_after: Option<i64>,
322 #[serde(default)]
323 data: Option<Value>,
324 #[serde(default)]
325 error: Option<Value>,
326}
327
328#[allow(clippy::too_many_arguments)]
329async fn run_connection<T>(
330 cfg: &Config,
331 opts: &WebSocketOptions,
332 url: &str,
333 payload: &Map<String, Value>,
334 identify: Option<fn(&T) -> String>,
335 mut dedup: Option<&mut Dedup>,
336 reconnect_at: &mut Option<Instant>,
337 tx: &mpsc::Sender<Result<T, Error>>,
338) -> ConnEnd
339where
340 T: DeserializeOwned,
341{
342 let mut request = match url.into_client_request() {
346 Ok(r) => r,
347 Err(e) => {
348 return ConnEnd::Terminal(Some(Error::WebSocket(format!("invalid URL: {e}"))));
349 }
350 };
351 let api_key = match HeaderValue::from_str(&cfg.api_key) {
352 Ok(v) => v,
353 Err(_) => return ConnEnd::Terminal(Some(Error::MissingApiKey)),
354 };
355 let headers = request.headers_mut();
356 headers.insert("x-api-key", api_key);
357 headers.insert("x-client-version", HeaderValue::from_static(CLIENT_VERSION));
358 if opts.takeover {
359 headers.insert("x-takeover", HeaderValue::from_static("true"));
360 }
361
362 let ws_config = WebSocketConfig::default()
363 .max_message_size(Some(MAX_ARTICLE_MESSAGE_SIZE))
364 .max_frame_size(Some(MAX_ARTICLE_MESSAGE_SIZE));
365 let (ws, _) = match timeout(
366 cfg.timeout,
367 connect_async_with_config(request, Some(ws_config), false),
368 )
369 .await
370 {
371 Err(_) => {
372 tracing::error!("finlight ws: connection timed out");
373 return ConnEnd::Reconnect { connected: false };
374 }
375 Ok(Err(WsError::Http(resp))) if resp.status().as_u16() == 429 => {
376 *reconnect_at = Some(Instant::now() + DIAL_RATE_LIMIT_BACKOFF);
377 tracing::warn!(
378 backoff = ?DIAL_RATE_LIMIT_BACKOFF,
379 "finlight ws: server rejected connection (429)"
380 );
381 return ConnEnd::Reconnect { connected: false };
382 }
383 Ok(Err(e)) => {
384 tracing::error!(error = %e, "finlight ws: connection failed");
385 return ConnEnd::Reconnect { connected: false };
386 }
387 Ok(Ok(ok)) => ok,
388 };
389
390 tracing::info!("finlight ws: connected");
391 *reconnect_at = None;
392
393 let (mut write, mut read) = ws.split();
394 let nonce = uuid::Uuid::new_v4().to_string();
395
396 let mut handshake = payload.clone();
397 handshake.insert("clientNonce".to_owned(), Value::String(nonce.clone()));
398 let handshake = serde_json::to_string(&Value::Object(handshake)).expect("valid JSON");
399 if let Err(e) = write.send(Message::text(handshake)).await {
400 tracing::error!(error = %e, "finlight ws: handshake write failed");
401 return ConnEnd::Reconnect { connected: true };
402 }
403
404 let mut last_pong = Instant::now();
405 let start = Instant::now();
406 let mut ping = interval_at(start + opts.ping_interval, opts.ping_interval);
407 let mut watchdog = interval_at(start + WATCHDOG_INTERVAL, WATCHDOG_INTERVAL);
408 let rotation = sleep(opts.connection_lifetime);
409 tokio::pin!(rotation);
410
411 loop {
412 tokio::select! {
413 msg = read.next() => match msg {
414 Some(Ok(Message::Text(text))) => {
415 if let Some(end) = handle_message(
416 text.as_str(), opts, &nonce, identify, dedup.as_deref_mut(),
417 reconnect_at, tx, &mut write, &mut last_pong,
418 ).await {
419 return end;
420 }
421 }
422 Some(Ok(Message::Close(frame))) => {
423 let (code, reason) = match &frame {
424 Some(f) => (u16::from(f.code), f.reason.to_string()),
425 None => (1005, String::new()),
426 };
427 tracing::info!(code, reason = %reason, "finlight ws: connection closed");
428 notify_close(opts, code, &reason);
429 if code == CLOSE_POLICY_VIOLATION {
430 tracing::warn!("finlight ws: connection rejected by server (blocked)");
431 return ConnEnd::Terminal(Some(Error::Blocked));
432 }
433 return ConnEnd::Reconnect { connected: true };
434 }
435 Some(Ok(_)) => {} Some(Err(e)) => {
437 tracing::info!(error = %e, "finlight ws: connection closed");
438 notify_close(opts, 1006, "");
439 return ConnEnd::Reconnect { connected: true };
440 }
441 None => {
442 tracing::info!("finlight ws: connection closed");
443 notify_close(opts, 1006, "");
444 return ConnEnd::Reconnect { connected: true };
445 }
446 },
447 _ = ping.tick() => {
448 let msg = json!({"action": "ping", "t": chrono::Utc::now().timestamp_millis()});
449 if let Err(e) = write.send(Message::text(msg.to_string())).await {
450 tracing::debug!(error = %e, "finlight ws: ping failed");
451 }
452 }
453 _ = watchdog.tick() => {
454 if last_pong.elapsed() > opts.pong_timeout {
455 tracing::warn!("finlight ws: no pong received in time, forcing reconnect");
456 close(&mut write, 1000, "pong timeout").await;
457 notify_close(opts, 1000, "pong timeout");
458 return ConnEnd::Reconnect { connected: true };
459 }
460 }
461 _ = &mut rotation => {
462 tracing::info!("finlight ws: proactive rotation before server connection cap");
463 close(&mut write, CLOSE_PROACTIVE_ROTATION, "Proactive rotation").await;
464 notify_close(opts, CLOSE_PROACTIVE_ROTATION, "Proactive rotation");
465 return ConnEnd::Reconnect { connected: true };
466 }
467 _ = tx.closed() => {
468 close(&mut write, 1000, "client stopped").await;
469 notify_close(opts, 1000, "client stopped");
470 return ConnEnd::Terminal(None);
471 }
472 }
473 }
474}
475
476#[allow(clippy::too_many_arguments)]
478async fn handle_message<T>(
479 text: &str,
480 opts: &WebSocketOptions,
481 nonce: &str,
482 identify: Option<fn(&T) -> String>,
483 dedup: Option<&mut Dedup>,
484 reconnect_at: &mut Option<Instant>,
485 tx: &mpsc::Sender<Result<T, Error>>,
486 write: &mut WsSink,
487 last_pong: &mut Instant,
488) -> Option<ConnEnd>
489where
490 T: DeserializeOwned,
491{
492 let msg: WsMessage = match serde_json::from_str(text) {
493 Ok(m) => m,
494 Err(e) => {
495 tracing::error!(error = %e, "finlight ws: cannot parse message");
496 return None;
497 }
498 };
499
500 match msg.action.as_str() {
501 "pong" => {
502 match msg.t {
503 Some(t) if t > 0 => {
504 let rtt = chrono::Utc::now().timestamp_millis() - t;
505 tracing::debug!(rtt_ms = rtt, "finlight ws: pong received");
506 }
507 _ => tracing::debug!("finlight ws: pong received"),
508 }
509 *last_pong = Instant::now();
510 }
511
512 "admit" => {
513 tracing::info!(lease_id = ?msg.lease_id, "finlight ws: admitted");
514 match &msg.client_nonce {
515 Some(got) if got != nonce => {
516 tracing::warn!(expected = nonce, got = %got, "finlight ws: nonce mismatch");
517 }
518 _ => {}
519 }
520 }
521
522 "preempted" => {
523 tracing::warn!(
524 reason = ?msg.reason,
525 new_lease_id = ?msg.new_lease_id,
526 "finlight ws: connection preempted"
527 );
528 close(write, 1000, "Preempted by server").await;
529 notify_close(opts, 1000, "client stopped");
530 return Some(ConnEnd::Terminal(None));
531 }
532
533 "sendArticle" => {
534 let article: T = match serde_json::from_value(msg.data.unwrap_or(Value::Null)) {
535 Ok(a) => a,
536 Err(e) => {
537 tracing::error!(error = %e, "finlight ws: cannot parse article");
538 return None;
539 }
540 };
541 if let (Some(identify), Some(dedup)) = (identify, dedup) {
542 let id = identify(&article);
543 if dedup.check_and_track(id.clone()) {
544 tracing::debug!(id = %id, "finlight ws: skipping duplicate article");
545 return None;
546 }
547 }
548 if tx.send(Ok(article)).await.is_err() {
549 close(write, 1000, "client stopped").await;
551 notify_close(opts, 1000, "client stopped");
552 return Some(ConnEnd::Terminal(None));
553 }
554 }
555
556 "admin_kick" => {
557 let retry_after = match msg.retry_after {
558 Some(ms) if ms > 0 => Duration::from_millis(ms as u64),
559 _ => DEFAULT_ADMIN_KICK_RETRY,
560 };
561 *reconnect_at = Some(Instant::now() + retry_after);
562 tracing::warn!(?retry_after, "finlight ws: admin kick");
563 close(write, CLOSE_ADMIN_KICK, "Admin kick").await;
564 notify_close(opts, CLOSE_ADMIN_KICK, "Admin kick");
565 return Some(ConnEnd::Reconnect { connected: true });
566 }
567
568 "error" => {
569 let err_text = value_to_string(msg.data.as_ref())
570 .or_else(|| value_to_string(msg.error.as_ref()))
571 .unwrap_or_default();
572 tracing::error!(error = %err_text, "finlight ws: server error");
573 let lowered = err_text.to_lowercase();
574 if lowered.contains("limit") {
575 *reconnect_at = Some(Instant::now() + ERROR_RATE_LIMIT_BACKOFF);
576 close(write, CLOSE_RATE_LIMITED, "Rate limited").await;
577 notify_close(opts, CLOSE_RATE_LIMITED, "Rate limited");
578 return Some(ConnEnd::Reconnect { connected: true });
579 } else if lowered.contains("blocked") {
580 *reconnect_at = Some(Instant::now() + ERROR_BLOCKED_BACKOFF);
581 close(write, CLOSE_USER_BLOCKED, "User blocked").await;
582 notify_close(opts, CLOSE_USER_BLOCKED, "User blocked");
583 return Some(ConnEnd::Reconnect { connected: true });
584 }
585 }
586
587 action => {
588 tracing::warn!(action = %action, "finlight ws: unknown message action");
589 }
590 }
591 None
592}
593
594async fn close(write: &mut WsSink, code: u16, reason: &str) {
595 let frame = CloseFrame {
596 code: CloseCode::from(code),
597 reason: reason.to_owned().into(),
598 };
599 let _ = write.send(Message::Close(Some(frame))).await;
600}
601
602fn notify_close(opts: &WebSocketOptions, code: u16, reason: &str) {
603 if let Some(on_close) = &opts.on_close {
604 on_close(code, reason);
605 }
606}
607
608fn value_to_string(v: Option<&Value>) -> Option<String> {
610 match v {
611 None | Some(Value::Null) => None,
612 Some(Value::String(s)) => Some(s.clone()),
613 Some(other) => Some(other.to_string()),
614 }
615}