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 #[cfg(feature = "hyperliquid")]
232 mod hyperliquid {
233 use super::*;
234 use crate::exchange::hyperliquid::Hyperliquid;
235
236 fn hyperliquid_ack(kind: &str, coin: &str) -> Result<WsMessage, WsError> {
237 Ok(WsMessage::text(format!(
238 r#"{{"channel":"subscriptionResponse","data":{{"method":"subscribe","subscription":{{"type":"{kind}","coin":"{coin}"}}}}}}"#
239 )))
240 }
241
242 fn hyperliquid_trade() -> Result<WsMessage, WsError> {
243 Ok(WsMessage::text(
244 r#"{"channel":"trades","data":[{"coin":"BTC","side":"B","px":"1","sz":"1","time":1,"hash":"0x","tid":1}]}"#,
245 ))
246 }
247
248 #[tokio::test(start_paused = true)]
249 async fn every_acknowledgement_validates_and_keeps_early_events() {
250 let mut messages = stream::iter([
251 hyperliquid_ack("trades", "BTC"),
252 hyperliquid_trade(),
253 hyperliquid_ack("l2Book", "ETH"),
254 ]);
255
256 let (map, buffered) = validate_responses::<Hyperliquid, _, _>(
257 map(&["trades|BTC", "l2Book|ETH"]),
258 &mut messages,
259 TIMEOUT,
260 )
261 .await
262 .unwrap();
263
264 assert_eq!(map.0.len(), 2);
265 assert_eq!(buffered.len(), 1);
266 }
267
268 #[tokio::test(start_paused = true)]
269 async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
270 let mut messages = stream::iter([
273 hyperliquid_ack("trades", "BTC"),
274 hyperliquid_ack("l2Book", "ETH"),
275 ]);
276
277 let error = validate_responses::<Hyperliquid, _, _>(
278 map(&["trades|BTC", "l2Book|ETH", "l2Book|XYZ:TSLA", "trades|@107"]),
279 &mut messages,
280 TIMEOUT,
281 )
282 .await
283 .unwrap_err();
284
285 let (reason, unacknowledged) = unacknowledged(error);
286 assert_eq!(reason, "WebSocket stream terminated unexpectedly");
287 assert_eq!(unacknowledged, ids(&["l2Book|XYZ:TSLA", "trades|@107"]));
288 }
289
290 #[tokio::test(start_paused = true)]
291 async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
292 let events = stream::unfold((), |()| async {
294 tokio::time::sleep(Duration::from_secs(1)).await;
295 Some((hyperliquid_trade(), ()))
296 });
297 let mut messages =
298 Box::pin(stream::iter([hyperliquid_ack("trades", "BTC")]).chain(events));
299 let started = tokio::time::Instant::now();
300
301 let error = validate_responses::<Hyperliquid, _, _>(
302 map(&["trades|BTC", "l2Book|ETH"]),
303 &mut messages,
304 TIMEOUT,
305 )
306 .await
307 .unwrap_err();
308
309 assert_eq!(started.elapsed(), TIMEOUT);
310 let (reason, unacknowledged) = unacknowledged(error);
311 assert!(reason.contains("timeout"), "{reason}");
312 assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
313 }
314
315 #[tokio::test(start_paused = true)]
316 async fn an_error_response_names_the_unacknowledged_subscriptions_with_its_reason() {
317 let mut messages = stream::iter([
318 hyperliquid_ack("trades", "BTC"),
319 Ok(WsMessage::text(
320 r#"{"channel":"error","data":"invalid subscription"}"#,
321 )),
322 ]);
323
324 let error = validate_responses::<Hyperliquid, _, _>(
325 map(&["trades|BTC", "l2Book|ETH"]),
326 &mut messages,
327 TIMEOUT,
328 )
329 .await
330 .unwrap_err();
331
332 let (reason, unacknowledged) = unacknowledged(error);
333 assert_eq!(
334 reason,
335 "received failure subscription response: invalid subscription"
336 );
337 assert_eq!(unacknowledged, ids(&["l2Book|ETH"]));
338 }
339
340 #[tokio::test(start_paused = true)]
341 async fn a_repeated_acknowledgement_names_the_whole_batch() {
342 let mut messages = stream::iter([
344 hyperliquid_ack("trades", "BTC"),
345 hyperliquid_ack("trades", "BTC"),
346 ]);
347
348 let error = validate_responses::<Hyperliquid, _, _>(
349 map(&["trades|BTC", "l2Book|ETH", "l2Book|SOL"]),
350 &mut messages,
351 TIMEOUT,
352 )
353 .await
354 .unwrap_err();
355
356 let (_, unacknowledged) = unacknowledged(error);
357 assert_eq!(
358 unacknowledged,
359 ids(&["l2Book|ETH", "l2Book|SOL", "trades|BTC"])
360 );
361 }
362
363 #[tokio::test(start_paused = true)]
364 async fn an_acknowledgement_naming_no_subscription_in_the_batch_names_the_whole_batch() {
365 let mut messages = stream::iter([hyperliquid_ack("trades", "SOL")]);
367
368 let error = validate_responses::<Hyperliquid, _, _>(
369 map(&["trades|BTC", "l2Book|ETH"]),
370 &mut messages,
371 TIMEOUT,
372 )
373 .await
374 .unwrap_err();
375
376 let (_, unacknowledged) = unacknowledged(error);
377 assert_eq!(unacknowledged, ids(&["l2Book|ETH", "trades|BTC"]));
378 }
379 }
380}