Skip to content

Commit d695598

Browse files
authored
Fix Rust SDK WebSocket HA mode origin discovery (#69)
2 parents 2ea21e3 + 81867a4 commit d695598

12 files changed

Lines changed: 414 additions & 95 deletions

File tree

rust/Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ Add the following to your `Cargo.toml`:
2020

2121
```toml
2222
[dependencies]
23-
chainlink-data-streams-report = "1.2.1"
24-
chainlink-data-streams-sdk = { version = "1.2.1", features = ["full"] }
23+
chainlink-data-streams-report = "1.2.2"
24+
chainlink-data-streams-sdk = { version = "1.2.2", features = ["full"] }
2525
```
2626

2727
#### Features
@@ -110,8 +110,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
110110

111111
let api_key = "YOUR_API_KEY_GOES_HERE";
112112
let user_secret = "YOUR_USER_SECRET_GOES_HERE";
113-
let rest_url = "https://api.testnet-dataengine.chain.link";
114-
let ws_url = "wss://ws.testnet-dataengine.chain.link";
113+
let rest_url = "https://api.dataengine.chain.link";
114+
let ws_url = "wss://ws.dataengine.chain.link";
115115

116116
let eth_usd_feed_id =
117117
ID::from_hex_str("0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782")

rust/crates/report/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "chainlink-data-streams-report"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
edition = "2021"
55
description = "Chainlink Data Streams Report"
66
license = "MIT"

rust/crates/sdk/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "chainlink-data-streams-sdk"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
edition = "2021"
55
rust-version = "1.70"
66
description = "Chainlink Data Streams client SDK"
@@ -11,7 +11,7 @@ exclude = ["/target/*", "examples/*", "tests/*", "docs/*", "book/*"]
1111
keywords = ["chainlink"]
1212

1313
[dependencies]
14-
chainlink-data-streams-report = { path = "../report", version = "1.2.1" }
14+
chainlink-data-streams-report = { path = "../report", version = "1.2.2" }
1515
reqwest = { version = "0.11.20", features = ["json", "rustls-tls"] }
1616
tokio = { version = "1.29.1", features = ["full"] }
1717
tokio-tungstenite = { version = "0.20.1", features = [

rust/crates/sdk/examples/wss_multiple.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1414

1515
let api_key = "YOUR_API_KEY_GOES_HERE";
1616
let user_secret = "YOUR_USER_SECRET_GOES_HERE";
17-
let rest_url = "https://api.testnet-dataengine.chain.link";
18-
let ws_url = "wss://ws.testnet-dataengine.chain.link,wss://ws.testnet-dataengine.chain.link";
17+
let rest_url = "https://api.dataengine.chain.link";
18+
let ws_url = "wss://ws.dataengine.chain.link";
1919

2020
let eth_usd_feed_id =
21-
ID::from_hex_str("0x000359843a543ee2fe414dc14c7e7920ef10f4372990b79d6361cdc0dd1ba782")
21+
ID::from_hex_str("0x000362205e10b3a147d02792eccee483dca6c7b44ecce7012cb8c6e0b68b3ae9")
2222
.unwrap();
2323
let btc_usd_feed_id: ID =
24-
ID::from_hex_str("0x00037da06d56d083fe599397a4769a042d63aa73dc4ef57709d31e9971a5b439")
24+
ID::from_hex_str("0x00039d9e45394f473ab1f050a1b963e6b05351e52d71e507509ada0c95ed75b8")
2525
.unwrap();
2626

2727
let feed_ids = vec![eth_usd_feed_id, btc_usd_feed_id];

rust/crates/sdk/src/config.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,13 @@ impl Config {
108108
/// .build()?;
109109
///
110110
/// // If you want to customize the configuration further, use the builder pattern
111-
/// let ws_urls_multiple = "wss://api.testnet-dataengine.chain.link/ws,wss://api.testnet-dataengine.chain.link/ws";
112-
///
111+
/// // In HA mode, provide a single WebSocket URL — origins are discovered automatically
112+
/// // via a HEAD request to the server (X-Cll-Available-Origins header).
113113
/// let config_custom = Config::new(
114114
/// api_key.to_string(),
115115
/// user_secret.to_string(),
116116
/// rest_url.to_string(),
117-
/// ws_urls_multiple.to_string(),
117+
/// ws_url.to_string(),
118118
/// )
119119
/// .with_ws_ha(WebSocketHighAvailability::Enabled) // Enable WebSocket High Availability Mode
120120
/// .with_ws_max_reconnect(10) // Set maximum reconnection attempts to 10, instead of the default 5.

rust/crates/sdk/src/endpoints.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,9 @@ impl CtxKey {
1919
}
2020

2121
/// HTTP Header constants using `HeaderName` with `OnceLock` for lazy initialization
22-
#[allow(dead_code)] // Currently unused
2322
static CLL_AVAIL_ORIGINS_HEADER: OnceLock<HeaderName> = OnceLock::new();
24-
#[allow(dead_code)] // Currently unused
2523
static CLL_ORIGIN_HEADER: OnceLock<HeaderName> = OnceLock::new();
26-
#[allow(dead_code)] // Currently unused
24+
#[allow(dead_code)]
2725
static CLL_INT_HEADER: OnceLock<HeaderName> = OnceLock::new();
2826
static AUTHZ_HEADER: OnceLock<HeaderName> = OnceLock::new();
2927
static AUTHZ_TS_HEADER: OnceLock<HeaderName> = OnceLock::new();
@@ -33,7 +31,6 @@ static HOST_HEADER: OnceLock<HeaderName> = OnceLock::new();
3331

3432
/// Functions to retrieve header constants, initializing them on first access
3533
36-
#[allow(dead_code)] // Currently unused
3734
/// "X-Cll-Available-Origins"
3835
pub fn get_cll_avail_origins_header() -> &'static HeaderName {
3936
CLL_AVAIL_ORIGINS_HEADER.get_or_init(|| {
@@ -42,16 +39,15 @@ pub fn get_cll_avail_origins_header() -> &'static HeaderName {
4239
})
4340
}
4441

45-
#[allow(dead_code)] // Currently unused
4642
/// "X-Cll-Origin"
4743
pub fn get_cll_origin_header() -> &'static HeaderName {
4844
CLL_ORIGIN_HEADER.get_or_init(|| {
4945
HeaderName::from_str("X-Cll-Origin").expect("Invalid header name: X-Cll-Origin")
5046
})
5147
}
5248

53-
#[allow(dead_code)] // Currently unused
5449
/// "X-Cll-Eng-Int"
50+
#[allow(dead_code)]
5551
pub fn get_cll_int_header() -> &'static HeaderName {
5652
CLL_INT_HEADER.get_or_init(|| {
5753
HeaderName::from_str("X-Cll-Eng-Int").expect("Invalid header name: X-Cll-Eng-Int")
@@ -86,3 +82,20 @@ pub fn get_authz_sig_header() -> &'static HeaderName {
8682
pub fn get_host_header() -> &'static HeaderName {
8783
HOST_HEADER.get_or_init(|| HeaderName::from_str("Host").expect("Invalid header name: Host"))
8884
}
85+
86+
#[cfg(test)]
87+
mod tests {
88+
use super::*;
89+
90+
#[test]
91+
fn test_cll_origin_header_name() {
92+
let h = get_cll_origin_header();
93+
assert_eq!(h.as_str(), "x-cll-origin");
94+
}
95+
96+
#[test]
97+
fn test_cll_avail_origins_header_name() {
98+
let h = get_cll_avail_origins_header();
99+
assert_eq!(h.as_str(), "x-cll-available-origins");
100+
}
101+
}

rust/crates/sdk/src/stream.rs

Lines changed: 166 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,30 @@ mod monitor_connection;
44
use establish_connection::connect;
55
use monitor_connection::run_stream;
66

7-
use crate::config::Config;
7+
use crate::auth::generate_auth_headers;
8+
use crate::config::{Config, WebSocketHighAvailability};
9+
use crate::endpoints::get_cll_avail_origins_header;
810

911
use chainlink_data_streams_report::feed_id::ID;
1012
use chainlink_data_streams_report::report::Report;
1113

14+
use reqwest::Client as HttpClient;
1215
use serde::{Deserialize, Serialize};
1316
use std::{
1417
collections::HashMap,
1518
sync::{
1619
atomic::{AtomicUsize, Ordering},
1720
Arc,
1821
},
22+
time::{SystemTime, UNIX_EPOCH},
1923
};
2024
use tokio::{
2125
net::TcpStream,
2226
sync::{broadcast, mpsc, Mutex},
2327
time::{sleep, Duration},
2428
};
2529
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream as TungsteniteWebSocketStream};
26-
use tracing::{debug, error, info};
30+
use tracing::{debug, error, info, warn};
2731

2832
pub const DEFAULT_WS_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
2933
pub const MIN_WS_RECONNECT_INTERVAL: Duration = Duration::from_millis(1000);
@@ -70,7 +74,7 @@ struct Stats {
7074
#[derive(Debug)]
7175
pub enum WebSocketConnection {
7276
Single(TungsteniteWebSocketStream<MaybeTlsStream<TcpStream>>),
73-
Multiple(Vec<TungsteniteWebSocketStream<MaybeTlsStream<TcpStream>>>),
77+
Multiple(Vec<(TungsteniteWebSocketStream<MaybeTlsStream<TcpStream>>, String)>),
7478
}
7579

7680
/// Stream represents a realtime report stream.
@@ -141,7 +145,26 @@ impl Stream {
141145
active_connections: AtomicUsize::new(0),
142146
});
143147

144-
let conn = connect(config, &feed_ids, stats.clone()).await?;
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?;
145168

146169
let water_mark = Arc::new(Mutex::new(HashMap::new()));
147170

@@ -176,6 +199,7 @@ impl Stream {
176199

177200
tokio::spawn(run_stream(
178201
stream,
202+
String::new(), // no X-Cll-Origin header for non-HA connections
179203
report_sender,
180204
shutdown_receiver,
181205
stats,
@@ -185,7 +209,7 @@ impl Stream {
185209
));
186210
}
187211
WebSocketConnection::Multiple(streams) => {
188-
for stream in streams {
212+
for (stream, origin) in streams {
189213
let report_sender = self.report_sender.clone();
190214
let shutdown_receiver = self.shutdown_sender.subscribe();
191215
let stats = self.stats.clone();
@@ -195,6 +219,7 @@ impl Stream {
195219

196220
tokio::spawn(run_stream(
197221
stream,
222+
origin,
198223
report_sender,
199224
shutdown_receiver,
200225
stats,
@@ -284,3 +309,139 @@ pub struct StatsSnapshot {
284309
/// Current number of active connections
285310
pub active_connections: usize,
286311
}
312+
313+
fn 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+
328+
fn 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+
338+
async 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)]
393+
mod 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+
}

0 commit comments

Comments
 (0)