1use crate::{
2 exchange::Connector,
3 subscription::{Map, SubscriptionKind},
4};
5use fnv::FnvHashSet;
6use futures::{Stream, StreamExt};
7use rustrade_integration::{
8 Validator,
9 error::SocketError,
10 protocol::{
11 StreamParser,
12 websocket::{WebSocket, WebSocketSerdeParser, WsError, WsMessage},
13 },
14 subscription::SubscriptionId,
15};
16use serde::{Deserialize, Serialize};
17use std::{future::Future, time::Duration};
18use tracing::debug;
19
20pub trait SubscriptionValidator {
23 type Parser;
24
25 fn validate<Exchange, InstrumentKey, Kind>(
26 instrument_map: Map<InstrumentKey>,
27 websocket: &mut WebSocket,
28 ) -> impl Future<Output = Result<(Map<InstrumentKey>, Vec<WsMessage>), SocketError>> + Send
29 where
30 Exchange: Connector + Send,
31 InstrumentKey: Send,
32 Kind: SubscriptionKind + Send;
33}
34
35#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
37pub struct WebSocketSubValidator;
38
39impl SubscriptionValidator for WebSocketSubValidator {
40 type Parser = WebSocketSerdeParser;
41
42 async fn validate<Exchange, Instrument, Kind>(
43 instrument_map: Map<Instrument>,
44 websocket: &mut WebSocket,
45 ) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
46 where
47 Exchange: Connector + Send,
48 Instrument: Send,
49 Kind: SubscriptionKind + Send,
50 {
51 validate_responses::<Exchange, _, _>(
52 instrument_map,
53 websocket,
54 Exchange::subscription_timeout(),
55 )
56 .await
57 }
58}
59
60async fn validate_responses<Exchange, Instrument, Messages>(
65 instrument_map: Map<Instrument>,
66 messages: &mut Messages,
67 timeout: Duration,
68) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
69where
70 Exchange: Connector,
71 Messages: Stream<Item = Result<WsMessage, WsError>> + Unpin,
72{
73 let expected_responses = Exchange::expected_responses(&instrument_map);
74 let mut acknowledged = Acknowledged::default();
75
76 let mut buff_active_subscription_events = Vec::new();
78
79 let deadline = tokio::time::sleep(timeout);
82 tokio::pin!(deadline);
83
84 loop {
85 if acknowledged.count == expected_responses {
87 debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
88 break Ok((instrument_map, buff_active_subscription_events));
89 }
90
91 let failure = tokio::select! {
92 _ = &mut deadline => format!("subscription validation timeout reached: {timeout:?}"),
93 message = messages.next() => {
94 let Some(response) = message else {
95 break Err(acknowledged.into_error(
96 "WebSocket stream terminated unexpectedly".to_owned(),
97 &instrument_map,
98 ));
99 };
100
101 match <WebSocketSerdeParser as StreamParser<Exchange::SubResponse>>::parse(response) {
102 Some(Ok(response)) => match response.validate() {
103 Ok(response) => {
105 acknowledged.record(
106 Exchange::acknowledged_subscription(&response),
107 &instrument_map,
108 );
109 debug!(
110 exchange = %Exchange::ID,
111 success_responses = %acknowledged.count,
112 %expected_responses,
113 payload = ?response,
114 "received valid Ok subscription response",
115 );
116 continue
117 }
118
119 Err(SocketError::Subscribe(message)) => message,
122 Err(error) => error.to_string(),
123 }
124 Some(Err(SocketError::Deserialise { error: _, payload })) => {
125 buff_active_subscription_events.push(WsMessage::text(payload));
128 continue
129 }
130 Some(Err(SocketError::Terminated(close_frame))) => {
131 format!("received WebSocket CloseFrame: {close_frame}")
132 }
133 _ => {
134 continue
136 }
137 }
138 }
139 };
140
141 break Err(acknowledged.into_error(failure, &instrument_map));
142 }
143}
144
145#[derive(Debug, Default)]
147struct Acknowledged {
148 count: usize,
150 matched: FnvHashSet<SubscriptionId>,
152}
153
154impl Acknowledged {
155 fn record<Instrument>(&mut self, id: Option<SubscriptionId>, map: &Map<Instrument>) {
156 self.count += 1;
157 if let Some(id) = id.filter(|id| map.0.contains_key(id)) {
158 self.matched.insert(id);
159 }
160 }
161
162 fn into_error<Instrument>(self, reason: String, map: &Map<Instrument>) -> SocketError {
169 let narrowed = self.matched.len() == self.count;
170 let mut unacknowledged = map
171 .0
172 .keys()
173 .filter(|id| !(narrowed && self.matched.contains(*id)))
174 .cloned()
175 .collect::<Vec<_>>();
176 unacknowledged.sort_unstable();
177
178 SocketError::Unacknowledged {
179 reason,
180 unacknowledged,
181 }
182 }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)] mod tests {
188 use super::*;
189 use crate::exchange::okx::Okx;
190 use futures::stream;
191
192 const TIMEOUT: Duration = Duration::from_secs(10);
193
194 fn map(ids: &[&str]) -> Map<usize> {
195 ids.iter()
196 .enumerate()
197 .map(|(key, id)| (SubscriptionId::from(*id), key))
198 .collect()
199 }
200
201 fn unacknowledged(error: SocketError) -> (String, Vec<SubscriptionId>) {
202 match error {
203 SocketError::Unacknowledged {
204 reason,
205 unacknowledged,
206 } => (reason, unacknowledged),
207 other => panic!("expected Unacknowledged, got {other:?}"),
208 }
209 }
210
211 fn ids(ids: &[&str]) -> Vec<SubscriptionId> {
212 ids.iter().map(|id| SubscriptionId::from(*id)).collect()
213 }
214
215 #[tokio::test(start_paused = true)]
216 async fn a_venue_that_does_not_echo_its_subscriptions_names_the_whole_batch() {
217 let mut messages = stream::iter([Ok(WsMessage::text(r#"{"event":"subscribe"}"#))]);
218
219 let error = validate_responses::<Okx, _, _>(
220 map(&["trades|BTC-USDT", "trades|ETH-USDT"]),
221 &mut messages,
222 TIMEOUT,
223 )
224 .await
225 .unwrap_err();
226
227 let (_, unacknowledged) = unacknowledged(error);
228 assert_eq!(unacknowledged, ids(&["trades|BTC-USDT", "trades|ETH-USDT"]));
229 }
230
231 mod kraken {
232 use super::*;
233 use crate::exchange::kraken::Kraken;
234
235 fn kraken_ack(channel: &str, symbol: &str) -> Result<WsMessage, WsError> {
236 Ok(WsMessage::text(format!(
237 r#"{{"method":"subscribe","result":{{"channel":"{channel}","snapshot":false,"symbol":"{symbol}"}},"success":true,"time_in":"2018-11-12T21:14:59.545897Z","time_out":"2018-11-12T21:14:59.545901Z"}}"#
238 )))
239 }
240
241 fn kraken_status() -> Result<WsMessage, WsError> {
242 Ok(WsMessage::text(
243 r#"{"channel":"status","type":"update","data":[{"api_version":"v2","connection_id":1,"system":"online","version":"2.0.0"}]}"#,
244 ))
245 }
246
247 #[tokio::test(start_paused = true)]
248 async fn every_acknowledgement_validates_past_the_status_message() {
249 let mut messages = stream::iter([
250 kraken_status(),
251 kraken_ack("trade", "BTC/USD"),
252 Ok(WsMessage::text(r#"{"channel":"heartbeat"}"#)),
253 kraken_ack("trade", "ETH/USD"),
254 ]);
255
256 let (map, buffered) = validate_responses::<Kraken, _, _>(
257 map(&["trade|BTC/USD", "trade|ETH/USD"]),
258 &mut messages,
259 TIMEOUT,
260 )
261 .await
262 .unwrap();
263
264 assert_eq!(map.0.len(), 2);
265 assert_eq!(buffered.len(), 2);
266 }
267
268 #[tokio::test(start_paused = true)]
269 async fn a_refused_symbol_is_named_alone() {
270 let mut messages = stream::iter([
271 kraken_status(),
272 kraken_ack("ticker", "BTC/USD"),
273 Ok(WsMessage::text(
274 r#"{"error":"Currency pair not supported XBT/USD","method":"subscribe","success":false,"symbol":"XBT/USD","time_in":"2018-11-12T21:14:59.545897Z","time_out":"2018-11-12T21:14:59.545901Z"}"#,
275 )),
276 ]);
277
278 let error = validate_responses::<Kraken, _, _>(
279 map(&["ticker|BTC/USD", "ticker|XBT/USD"]),
280 &mut messages,
281 TIMEOUT,
282 )
283 .await
284 .unwrap_err();
285
286 let (reason, unacknowledged) = unacknowledged(error);
287 assert_eq!(
288 reason,
289 "received failure subscription response for XBT/USD: Currency pair not supported XBT/USD"
290 );
291 assert_eq!(unacknowledged, ids(&["ticker|XBT/USD"]));
292 }
293 }
294
295 #[cfg(feature = "hyperliquid")]
296 mod hyperliquid {
297 use super::*;
298 use crate::exchange::hyperliquid::Hyperliquid;
299
300 fn hyperliquid_ack(kind: &str, coin: &str) -> Result<WsMessage, WsError> {
301 Ok(WsMessage::text(format!(
302 r#"{{"channel":"subscriptionResponse","data":{{"method":"subscribe","subscription":{{"type":"{kind}","coin":"{coin}"}}}}}}"#
303 )))
304 }
305
306 fn hyperliquid_trade() -> Result<WsMessage, WsError> {
307 Ok(WsMessage::text(
308 r#"{"channel":"trades","data":[{"coin":"BTC","side":"B","px":"1","sz":"1","time":1,"hash":"0x","tid":1}]}"#,
309 ))
310 }
311
312 #[tokio::test(start_paused = true)]
313 async fn every_acknowledgement_validates_and_keeps_early_events() {
314 let mut messages = stream::iter([
315 hyperliquid_ack("trades", "BTC"),
316 hyperliquid_trade(),
317 hyperliquid_ack("l2Book", "ETH"),
318 ]);
319
320 let (map, buffered) = validate_responses::<Hyperliquid, _, _>(
321 map(&["trades|BTC", "l2Book|ETH"]),
322 &mut messages,
323 TIMEOUT,
324 )
325 .await
326 .unwrap();
327
328 assert_eq!(map.0.len(), 2);
329 assert_eq!(buffered.len(), 1);
330 }
331
332 #[tokio::test(start_paused = true)]
333 async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
334 let mut messages = stream::iter([
337 hyperliquid_ack("trades", "BTC"),
338 hyperliquid_ack("l2Book", "ETH"),
339 ]);
340
341 let error = validate_responses::<Hyperliquid, _, _>(
342 map(&["trades|BTC", "l2Book|ETH", "l2Book|XYZ:TSLA", "trades|@107"]),
343 &mut messages,
344 TIMEOUT,
345 )
346 .await
347 .unwrap_err();
348
349 let (reason, unacknowledged) = unacknowledged(error);
350 assert_eq!(reason, "WebSocket stream terminated unexpectedly");
351 assert_eq!(unacknowledged, ids(&["l2Book|XYZ:TSLA", "trades|@107"]));
352 }
353
354 #[tokio::test(start_paused = true)]
355 async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
356 let events = stream::unfold((), |()| async {
358 tokio::time::sleep(Duration::from_secs(1)).await;
359 Some((hyperliquid_trade(), ()))
360 });
361 let mut messages =
362 Box::pin(stream::iter([hyperliquid_ack("trades", "BTC")]).chain(events));
363 let started = tokio::time::Instant::now();
364
365 let error = validate_responses::<Hyperliquid, _, _>(
366 map(&["trades|BTC", "l2Book|ETH"]),
367 &mut messages,
368 TIMEOUT,
369 )
370 .await
371 .unwrap_err();
372
373 assert_eq!(started.elapsed(), TIMEOUT);
374 let (reason, unacknowledged) = unacknowledged(error);
375 assert!(reason.contains("timeout"), "{reason}");
376 assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
377 }
378
379 #[tokio::test(start_paused = true)]
380 async fn an_error_response_names_the_unacknowledged_subscriptions_with_its_reason() {
381 let mut messages = stream::iter([
382 hyperliquid_ack("trades", "BTC"),
383 Ok(WsMessage::text(
384 r#"{"channel":"error","data":"invalid subscription"}"#,
385 )),
386 ]);
387
388 let error = validate_responses::<Hyperliquid, _, _>(
389 map(&["trades|BTC", "l2Book|ETH"]),
390 &mut messages,
391 TIMEOUT,
392 )
393 .await
394 .unwrap_err();
395
396 let (reason, unacknowledged) = unacknowledged(error);
397 assert_eq!(
398 reason,
399 "received failure subscription response: invalid subscription"
400 );
401 assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
402 }
403
404 #[tokio::test(start_paused = true)]
405 async fn a_repeated_acknowledgement_names_the_whole_batch() {
406 let mut messages = stream::iter([
408 hyperliquid_ack("trades", "BTC"),
409 hyperliquid_ack("trades", "BTC"),
410 ]);
411
412 let error = validate_responses::<Hyperliquid, _, _>(
413 map(&["trades|BTC", "l2Book|ETH", "l2Book|SOL"]),
414 &mut messages,
415 TIMEOUT,
416 )
417 .await
418 .unwrap_err();
419
420 let (_, unacknowledged) = unacknowledged(error);
421 assert_eq!(
422 unacknowledged,
423 ids(&["l2Book|ETH", "l2Book|SOL", "trades|BTC"])
424 );
425 }
426
427 #[tokio::test(start_paused = true)]
428 async fn an_acknowledgement_naming_no_subscription_in_the_batch_names_the_whole_batch() {
429 let mut messages = stream::iter([hyperliquid_ack("trades", "SOL")]);
431
432 let error = validate_responses::<Hyperliquid, _, _>(
433 map(&["trades|BTC", "l2Book|ETH"]),
434 &mut messages,
435 TIMEOUT,
436 )
437 .await
438 .unwrap_err();
439
440 let (_, unacknowledged) = unacknowledged(error);
441 assert_eq!(unacknowledged, ids(&["l2Book|ETH", "trades|BTC"]));
442 }
443 }
444}