Skip to main content

chainlink_data_streams_sdk/
stream.rs

1mod establish_connection;
2mod monitor_connection;
3
4use establish_connection::connect;
5use monitor_connection::run_stream;
6
7use crate::auth::generate_auth_headers;
8use crate::config::{Config, WebSocketHighAvailability};
9use crate::endpoints::get_cll_avail_origins_header;
10
11use chainlink_data_streams_report::feed_id::ID;
12use chainlink_data_streams_report::report::Report;
13
14use reqwest::Client as HttpClient;
15use serde::{Deserialize, Serialize};
16use std::{
17    collections::HashMap,
18    sync::{
19        atomic::{AtomicUsize, Ordering},
20        Arc,
21    },
22    time::{SystemTime, UNIX_EPOCH},
23};
24use tokio::{
25    net::TcpStream,
26    sync::{broadcast, mpsc, Mutex},
27    time::{sleep, Duration},
28};
29use tokio_tungstenite::{MaybeTlsStream, WebSocketStream as TungsteniteWebSocketStream};
30use tracing::{debug, error, info, warn};
31
32pub const DEFAULT_WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
33pub const MIN_WS_RECONNECT_INTERVAL: Duration = Duration::from_millis(1000);
34pub const MAX_WS_RECONNECT_INTERVAL: Duration = Duration::from_millis(10000);
35
36#[derive(Debug, thiserror::Error)]
37pub enum StreamError {
38    #[error("WebSocket error: {0}")]
39    WebSocketError(#[from] tokio_tungstenite::tungstenite::Error),
40
41    #[error("Connection error: {0}")]
42    ConnectionError(String),
43
44    #[error("Authentication error: {0}")]
45    AuthError(#[from] crate::auth::HmacError),
46
47    #[error("Serialization error: {0}")]
48    SerializationError(#[from] serde_json::Error),
49
50    #[error("Stream closed")]
51    StreamClosed,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct WebSocketReport {
56    pub report: Report,
57}
58
59struct Stats {
60    /// Total number of accepted reports
61    accepted: AtomicUsize,
62    /// Total number of deduplicated reports when in HA           
63    deduplicated: AtomicUsize,
64    /// Total number of partial reconnects when in HA        
65    partial_reconnects: AtomicUsize,
66    /// Total number of full reconnects    
67    full_reconnects: AtomicUsize,
68    /// Number of configured connections if in HA      
69    configured_connections: AtomicUsize,
70    /// Current number of active connections     
71    active_connections: AtomicUsize,
72}
73
74#[derive(Debug)]
75pub enum WebSocketConnection {
76    Single(TungsteniteWebSocketStream<MaybeTlsStream<TcpStream>>),
77    Multiple(Vec<(TungsteniteWebSocketStream<MaybeTlsStream<TcpStream>>, String)>),
78}
79
80/// Stream represents a realtime report stream.
81/// Safe for concurrent usage.
82/// When HA mode is enabled and at least 2 origins are provided, the Stream will maintain at least 2 concurrent connections to different instances
83/// to ensure high availability, fault tolerance and minimize the risk of report gaps.
84pub struct Stream {
85    config: Config,
86    feed_ids: Vec<ID>,
87    conn: Option<WebSocketConnection>,
88    report_sender: mpsc::Sender<WebSocketReport>,
89    report_receiver: mpsc::Receiver<WebSocketReport>,
90    shutdown_sender: broadcast::Sender<()>,
91    stats: Arc<Stats>,
92    water_mark: Arc<Mutex<HashMap<String, usize>>>,
93}
94
95impl Stream {
96    /// Establishes a streaming WebSocket connection that sends reports for the given feedID(s) after they are verified.
97    ///
98    /// # Arguments
99    ///
100    /// * `config` - A validated `Config` instance.
101    /// * `feedIDs` - A comma-separated list of Data Streams feed IDs.
102    ///
103    /// # Endpoint:
104    /// ```bash
105    /// /api/v1/ws
106    /// ```
107    ///
108    /// # Type:
109    /// * WebSocket
110    ///
111    /// # Sample Request:
112    /// ```bash
113    /// GET /api/v1/ws?feedIDs=<feedID1>,<feedID2>,...
114    /// ```
115    ///
116    /// # Sample Response:
117    /// ```json
118    /// {
119    ///     "report": {
120    ///         "feedID": "Hex encoded feedId.",
121    ///         "fullReport": "A blob containing the report context and body. Encode the fee token into the payload before passing it to the contract for verification.",
122    ///         "validFromTimestamp": "Report's earliest applicable timestamp (in seconds).",
123    ///         "observationsTimestamp": "Report's latest applicable timestamp (in seconds)."
124    ///     }
125    /// }
126    /// ```
127    ///
128    /// # Error Response Codes
129    ///
130    /// | Status Code | Description |
131    /// |-------------|-------------|
132    /// | **400 Bad Request** | This error is triggered when:<br>- There is any missing/malformed query argument.<br>- Required headers are missing or provided with incorrect values. |
133    /// | **401 Unauthorized User** | This error is triggered when:<br>- Authentication fails, typically because the HMAC signature provided by the client doesn't match the one expected by the server.<br>- A user requests access to a feed without the appropriate permission or that does not exist. |
134    /// | **500 Internal Server** | Indicates an unexpected condition encountered by the server, preventing it from fulfilling the request. This error typically points to issues on the server side. |
135    pub async fn new(config: &Config, feed_ids: Vec<ID>) -> Result<Stream, StreamError> {
136        let (report_sender, report_receiver) = mpsc::channel(100);
137        let (shutdown_sender, _) = broadcast::channel(1);
138
139        let stats = Arc::new(Stats {
140            accepted: AtomicUsize::new(0),
141            deduplicated: AtomicUsize::new(0),
142            partial_reconnects: AtomicUsize::new(0),
143            full_reconnects: AtomicUsize::new(0),
144            configured_connections: AtomicUsize::new(0),
145            active_connections: AtomicUsize::new(0),
146        });
147
148        let origins: Vec<String> = if config.ws_ha == WebSocketHighAvailability::Enabled {
149            match fetch_ha_origins(config).await {
150                Ok(o) if !o.is_empty() => {
151                    info!("HA mode: discovered {} origins", o.len());
152                    o
153                }
154                Ok(_) => {
155                    warn!("HA mode: no origins returned from HEAD request, degrading to single connection");
156                    vec![]
157                }
158                Err(e) => {
159                    warn!("HA mode: origin discovery failed ({}), degrading to single connection", e);
160                    vec![]
161                }
162            }
163        } else {
164            vec![]
165        };
166
167        let conn = connect(config, &origins, &feed_ids, stats.clone()).await?;
168
169        let water_mark = Arc::new(Mutex::new(HashMap::new()));
170
171        Ok(Stream {
172            config: config.clone(),
173            feed_ids,
174            conn: Some(conn),
175            report_sender,
176            report_receiver,
177            shutdown_sender,
178            stats,
179            water_mark,
180        })
181    }
182
183    /// Starts listening for reports on the Stream.
184    /// This method will spawn a new task for each WebSocket connection.
185    pub async fn listen(&mut self) -> Result<(), StreamError> {
186        let conn = self
187            .conn
188            .take()
189            .ok_or_else(|| StreamError::ConnectionError("No connection".into()))?;
190
191        match conn {
192            WebSocketConnection::Single(stream) => {
193                let report_sender = self.report_sender.clone();
194                let shutdown_receiver = self.shutdown_sender.subscribe();
195                let stats = self.stats.clone();
196                let water_mark = self.water_mark.clone();
197                let config = self.config.clone();
198                let feed_ids = self.feed_ids.clone();
199
200                tokio::spawn(run_stream(
201                    stream,
202                    String::new(), // no X-Cll-Origin header for non-HA connections
203                    report_sender,
204                    shutdown_receiver,
205                    stats,
206                    water_mark,
207                    config,
208                    feed_ids,
209                ));
210            }
211            WebSocketConnection::Multiple(streams) => {
212                for (stream, origin) in streams {
213                    let report_sender = self.report_sender.clone();
214                    let shutdown_receiver = self.shutdown_sender.subscribe();
215                    let stats = self.stats.clone();
216                    let water_mark = self.water_mark.clone();
217                    let config = self.config.clone();
218                    let feed_ids = self.feed_ids.clone();
219
220                    tokio::spawn(run_stream(
221                        stream,
222                        origin,
223                        report_sender,
224                        shutdown_receiver,
225                        stats,
226                        water_mark,
227                        config,
228                        feed_ids,
229                    ));
230                }
231            }
232        }
233
234        Ok(())
235    }
236
237    /// Reads the next available report on the Stream.
238    /// Reads blocks until a report is received, the context is canceled or all underlying connections are in a error state.
239    ///
240    /// # Returns
241    ///
242    /// * `WebSocketReport` - The next available report.
243    pub async fn read(&mut self) -> Result<WebSocketReport, StreamError> {
244        self.report_receiver
245            .recv()
246            .await
247            .ok_or(StreamError::StreamClosed)
248    }
249
250    /// Closes the Stream.
251    /// It is the caller's responsibility to call close when the stream is no longer needed.
252    pub async fn close(&mut self) -> Result<(), StreamError> {
253        info!("Closing stream...");
254
255        // Send shutdown signal
256        if let Err(e) = self.shutdown_sender.send(()) {
257            debug!("Shutdown signal not sent (no active receivers). Stream may already be closed. Error received: {:?}", e);
258        }
259
260        // Allow tasks to shut down gracefully
261        sleep(Duration::from_millis(100)).await;
262
263        Ok(())
264    }
265
266    /// Returns basic stats about the Stream.
267    ///
268    /// # Returns
269    ///
270    /// * `StatsSnapshot` - A snapshot of the current Stream statistics.
271    ///     * `accepted` - Total number of accepted reports.
272    ///     * `deduplicated` - Total number of deduplicated reports when in HA.
273    ///     * `total_received` - Total number of received reports.
274    ///     * `partial_reconnects` - Total number of partial reconnects when in HA.
275    ///     * `full_reconnects` - Total number of full reconnects.
276    ///     * `configured_connections` - Number of configured connections if in HA.
277    ///     * `active_connections` - Current number of active connections.
278    pub fn get_stats(&self) -> StatsSnapshot {
279        let accepted = self.stats.accepted.load(Ordering::SeqCst);
280        let deduplicated = self.stats.deduplicated.load(Ordering::SeqCst);
281
282        StatsSnapshot {
283            accepted,
284            deduplicated,
285            total_received: accepted + deduplicated,
286            partial_reconnects: self.stats.partial_reconnects.load(Ordering::SeqCst),
287            full_reconnects: self.stats.full_reconnects.load(Ordering::SeqCst),
288            configured_connections: self.stats.configured_connections.load(Ordering::SeqCst),
289            active_connections: self.stats.active_connections.load(Ordering::SeqCst),
290        }
291    }
292}
293
294/// Snapshot of statistics for external consumption.
295#[derive(Debug, Clone)]
296pub struct StatsSnapshot {
297    /// Total number of accepted reports
298    pub accepted: usize,
299    /// Total number of deduplicated reports when in HA
300    pub deduplicated: usize,
301    /// Total number of received reports
302    pub total_received: usize,
303    /// Total number of partial reconnects when in HA
304    pub partial_reconnects: usize,
305    /// Total number of full reconnects
306    pub full_reconnects: usize,
307    /// Number of configured connections if in HA
308    pub configured_connections: usize,
309    /// Current number of active connections
310    pub active_connections: usize,
311}
312
313fn parse_origins_from_header(header_value: &str) -> Vec<String> {
314    let inner = header_value
315        .strip_prefix('{')
316        .and_then(|s| s.strip_suffix('}'))
317        .unwrap_or(header_value);
318    if inner.is_empty() {
319        return vec![];
320    }
321    inner
322        .split(',')
323        .map(|s| s.trim().to_string())
324        .filter(|s| !s.is_empty())
325        .collect()
326}
327
328fn convert_ws_to_http_scheme(ws_url: &str) -> String {
329    if let Some(rest) = ws_url.strip_prefix("wss://") {
330        format!("https://{}", rest)
331    } else if let Some(rest) = ws_url.strip_prefix("ws://") {
332        format!("http://{}", rest)
333    } else {
334        ws_url.to_string()
335    }
336}
337
338async fn fetch_ha_origins(config: &Config) -> Result<Vec<String>, StreamError> {
339    let http = HttpClient::builder()
340        .danger_accept_invalid_certs(config.insecure_skip_verify.to_bool())
341        .build()
342        .map_err(|e| StreamError::ConnectionError(e.to_string()))?;
343
344    // Parse URL, normalize path to "/", keep scheme+host+port so the HMAC-signed
345    // path "/" matches the actual request path even when ws_url carries a subpath.
346    let http_url = {
347        let mut u = reqwest::Url::parse(&convert_ws_to_http_scheme(&config.ws_url))
348            .map_err(|e| StreamError::ConnectionError(format!("Invalid ws_url: {}", e)))?;
349        u.set_path("/");
350        u.set_query(None);
351        u.to_string()
352    };
353
354    let request_timestamp = SystemTime::now()
355        .duration_since(UNIX_EPOCH)
356        .expect("System time error")
357        .as_millis();
358
359    let auth_headers = generate_auth_headers(
360        "HEAD",
361        "/",
362        b"",
363        &config.api_key,
364        &config.api_secret,
365        request_timestamp,
366    )?;
367
368    let response = http
369        .head(&http_url)
370        .headers(auth_headers)
371        .send()
372        .await
373        .map_err(|e| StreamError::ConnectionError(format!("HA origin discovery request failed: {}", e)))?;
374
375    if !response.status().is_success() {
376        return Err(StreamError::ConnectionError(format!(
377            "HA origin discovery HEAD request returned status {}",
378            response.status()
379        )));
380    }
381
382    let header_value = response
383        .headers()
384        .get(get_cll_avail_origins_header())
385        .and_then(|v| v.to_str().ok())
386        .unwrap_or("")
387        .to_string();
388
389    Ok(parse_origins_from_header(&header_value))
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_parse_origins_from_header_empty() {
398        assert_eq!(parse_origins_from_header(""), Vec::<String>::new());
399    }
400
401    #[test]
402    fn test_parse_origins_from_header_with_braces() {
403        let result = parse_origins_from_header("{001,002}");
404        assert_eq!(result, vec!["001".to_string(), "002".to_string()]);
405    }
406
407    #[test]
408    fn test_parse_origins_from_header_without_braces() {
409        let result = parse_origins_from_header("001,002");
410        assert_eq!(result, vec!["001".to_string(), "002".to_string()]);
411    }
412
413    #[test]
414    fn test_parse_origins_from_header_single_origin() {
415        let result = parse_origins_from_header("{001}");
416        assert_eq!(result, vec!["001".to_string()]);
417    }
418
419    #[test]
420    fn test_parse_origins_from_header_empty_braces() {
421        assert_eq!(parse_origins_from_header("{}"), Vec::<String>::new());
422    }
423
424    #[test]
425    fn test_convert_ws_scheme_wss() {
426        assert_eq!(
427            convert_ws_to_http_scheme("wss://ws.dataengine.chain.link"),
428            "https://ws.dataengine.chain.link"
429        );
430    }
431
432    #[test]
433    fn test_convert_ws_scheme_ws() {
434        assert_eq!(
435            convert_ws_to_http_scheme("ws://127.0.0.1:8080"),
436            "http://127.0.0.1:8080"
437        );
438    }
439
440    #[test]
441    fn test_convert_ws_scheme_passthrough() {
442        assert_eq!(
443            convert_ws_to_http_scheme("https://already.https.com"),
444            "https://already.https.com"
445        );
446    }
447}