Skip to main content

finlight_client/
websocket.rs

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
30/// Called when a WebSocket connection closes, with the close code and reason.
31pub type OnClose = Arc<dyn Fn(u16, &str) + Send + Sync>;
32
33/// Tunes the streaming clients. The defaults match the sibling clients.
34#[derive(Clone)]
35pub struct WebSocketOptions {
36    /// Application-level ping cadence, default 25s.
37    pub ping_interval: Duration,
38    /// Force reconnect when no pong arrives within this window, default 60s.
39    pub pong_timeout: Duration,
40    /// First reconnect backoff, default 500ms.
41    pub base_reconnect_delay: Duration,
42    /// Backoff cap, default 10s.
43    pub max_reconnect_delay: Duration,
44    /// Proactive connection rotation, default 115min (under the 2h server cap).
45    pub connection_lifetime: Duration,
46    /// Take over an existing connection for the same key.
47    pub takeover: bool,
48    /// Called with (code, reason) whenever a connection closes.
49    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
80// Close codes used by the finlight WebSocket protocol.
81const CLOSE_PROACTIVE_ROTATION: u16 = 4000;
82const CLOSE_RATE_LIMITED: u16 = 4001;
83const CLOSE_USER_BLOCKED: u16 = 4002;
84const CLOSE_ADMIN_KICK: u16 = 4003;
85/// 1008: the server permanently rejected the connection.
86const 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
96/// Streams enriched articles in real time. Duplicate articles (same link
97/// within the last 10 deliveries) are suppressed.
98pub struct WebSocketClient {
99    cfg: Config,
100    opts: WebSocketOptions,
101}
102
103impl WebSocketClient {
104    /// Returns a streaming client with custom options. Client instances
105    /// created by [`Client::new`](crate::Client::new) use default options.
106    pub fn new(cfg: Config, opts: WebSocketOptions) -> Self {
107        Self { cfg, opts }
108    }
109
110    /// Connects to the finlight WebSocket and yields articles matching
111    /// `params`. Reconnects (exponential backoff, proactive rotation,
112    /// rate-limit waits) are handled internally. `Err` items are terminal
113    /// (e.g. [`Error::Blocked`]); the stream ends after yielding one. End the
114    /// stream by dropping it.
115    ///
116    /// Must be called within a tokio runtime.
117    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            &params,
123            Some(|a: &Article| a.link.clone()),
124        )
125    }
126}
127
128/// Streams unenriched articles in real time (no sentiment, entities, or
129/// content — lower latency). No duplicate suppression.
130pub struct RawWebSocketClient {
131    cfg: Config,
132    opts: WebSocketOptions,
133}
134
135impl RawWebSocketClient {
136    /// Returns a raw streaming client with custom options. Client instances
137    /// created by [`Client::new`](crate::Client::new) use default options.
138    pub fn new(cfg: Config, opts: WebSocketOptions) -> Self {
139        Self { cfg, opts }
140    }
141
142    /// Connects to the raw finlight WebSocket and yields articles matching
143    /// `params`. See [`WebSocketClient::stream`] for the streaming semantics.
144    ///
145    /// Must be called within a tokio runtime.
146    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            &params,
152            None,
153        )
154    }
155}
156
157/// A stream of articles delivered over the finlight WebSocket. Dropping the
158/// stream disconnects and stops the background task.
159pub 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
197/// FIFO cache of recently seen article keys for duplicate suppression.
198struct 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    /// Records `id`; returns true when it was already tracked.
212    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
227/// How one connection ended.
228enum ConnEnd {
229    /// Reconnect; `connected` reports whether the connection was established
230    /// (resets the backoff).
231    Reconnect { connected: bool },
232    /// Stop the reconnect loop, optionally yielding a terminal error.
233    Terminal(Option<Error>),
234}
235
236/// The reconnect loop: runs connections until the stream ends, waiting
237/// between attempts with exponential backoff or until a server-mandated
238/// reconnect time.
239async 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/// Envelope for every server message.
305#[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    /// Milliseconds.
320    #[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    // The server reads these headers case-sensitively in exact lowercase.
343    // The http crate always serializes header names in lowercase, so plain
344    // inserts are safe here (unlike Go's net/http, which canonicalizes).
345    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(_)) => {} // binary/ping/pong frames: not part of the protocol
436                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/// Dispatches one server message. Returns `Some` when the connection is done.
477#[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                // Consumer dropped the stream.
550                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
608/// Renders a JSON value that may be a string or arbitrary JSON.
609fn 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}