Skip to content

Commit a5d77b5

Browse files
committed
rust: Fix HA WebSocket mode to discover origins via HEAD request and set X-Cll-Origin header
1 parent 2ea21e3 commit a5d77b5

8 files changed

Lines changed: 331 additions & 84 deletions

File tree

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: 182 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,155 @@ 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(
404+
"{wss://ws1.dataengine.chain.link,wss://ws2.dataengine.chain.link}",
405+
);
406+
assert_eq!(
407+
result,
408+
vec![
409+
"wss://ws1.dataengine.chain.link".to_string(),
410+
"wss://ws2.dataengine.chain.link".to_string(),
411+
]
412+
);
413+
}
414+
415+
#[test]
416+
fn test_parse_origins_from_header_without_braces() {
417+
let result = parse_origins_from_header(
418+
"wss://ws1.dataengine.chain.link,wss://ws2.dataengine.chain.link",
419+
);
420+
assert_eq!(
421+
result,
422+
vec![
423+
"wss://ws1.dataengine.chain.link".to_string(),
424+
"wss://ws2.dataengine.chain.link".to_string(),
425+
]
426+
);
427+
}
428+
429+
#[test]
430+
fn test_parse_origins_from_header_single_origin() {
431+
let result = parse_origins_from_header("{wss://ws1.dataengine.chain.link}");
432+
assert_eq!(result, vec!["wss://ws1.dataengine.chain.link".to_string()]);
433+
}
434+
435+
#[test]
436+
fn test_parse_origins_from_header_empty_braces() {
437+
assert_eq!(parse_origins_from_header("{}"), Vec::<String>::new());
438+
}
439+
440+
#[test]
441+
fn test_convert_ws_scheme_wss() {
442+
assert_eq!(
443+
convert_ws_to_http_scheme("wss://ws.dataengine.chain.link"),
444+
"https://ws.dataengine.chain.link"
445+
);
446+
}
447+
448+
#[test]
449+
fn test_convert_ws_scheme_ws() {
450+
assert_eq!(
451+
convert_ws_to_http_scheme("ws://127.0.0.1:8080"),
452+
"http://127.0.0.1:8080"
453+
);
454+
}
455+
456+
#[test]
457+
fn test_convert_ws_scheme_passthrough() {
458+
assert_eq!(
459+
convert_ws_to_http_scheme("https://already.https.com"),
460+
"https://already.https.com"
461+
);
462+
}
463+
}

0 commit comments

Comments
 (0)