1use super::subscription::{BitfinexPlatformEvent, BitfinexSubResponse};
2use crate::{
3 Identifier,
4 exchange::{Connector, ExchangeSub},
5 subscriber::validator::SubscriptionValidator,
6 subscription::{Map, SubscriptionKind},
7};
8use fnv::FnvHashSet;
9use futures::{Stream, StreamExt};
10use rustrade_integration::{
11 Validator,
12 error::SocketError,
13 protocol::{
14 StreamParser,
15 websocket::{WebSocket, WebSocketSerdeParser, WsError, WsMessage},
16 },
17 subscription::SubscriptionId,
18};
19use serde::{Deserialize, Serialize};
20use smol_str::ToSmolStr;
21use std::time::Duration;
22use tracing::debug;
23
24#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
40pub struct BitfinexWebSocketSubValidator;
41
42impl SubscriptionValidator for BitfinexWebSocketSubValidator {
43 type Parser = WebSocketSerdeParser;
44
45 async fn validate<Exchange, Instrument, Kind>(
46 instrument_map: Map<Instrument>,
47 websocket: &mut WebSocket,
48 ) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
49 where
50 Exchange: Connector + Send,
51 Instrument: Send,
52 Kind: SubscriptionKind + Send,
53 {
54 validate_responses::<Exchange, _, _>(
55 instrument_map,
56 websocket,
57 Exchange::subscription_timeout(),
58 )
59 .await
60 }
61}
62
63async fn validate_responses<Exchange, Instrument, Messages>(
69 mut instrument_map: Map<Instrument>,
70 messages: &mut Messages,
71 timeout: Duration,
72) -> Result<(Map<Instrument>, Vec<WsMessage>), SocketError>
73where
74 Exchange: Connector,
75 Messages: Stream<Item = Result<WsMessage, WsError>> + Unpin,
76{
77 let expected_responses = Exchange::expected_responses(&instrument_map);
78 let mut success_responses = 0usize;
79 let mut init_snapshots_received = 0usize;
80
81 let mut unacknowledged = instrument_map.0.keys().cloned().collect::<FnvHashSet<_>>();
83
84 let mut awaiting_snapshot = FnvHashSet::default();
86
87 let mut buff_active_subscription_events = Vec::new();
89
90 let deadline = tokio::time::sleep(timeout);
93 tokio::pin!(deadline);
94
95 loop {
96 if success_responses == expected_responses && init_snapshots_received == expected_responses
98 {
99 debug!(exchange = %Exchange::ID, "validated exchange WebSocket subscriptions");
100 break Ok((instrument_map, buff_active_subscription_events));
101 }
102
103 let failure = tokio::select! {
104 _ = &mut deadline => format!("subscription validation timeout reached: {timeout:?}"),
105 message = messages.next() => {
106 let Some(response) = message else {
107 break Err(failure_error(
108 "WebSocket stream terminated unexpectedly".to_owned(),
109 unacknowledged,
110 init_snapshots_received,
111 expected_responses,
112 ));
113 };
114
115 match <WebSocketSerdeParser as StreamParser<BitfinexPlatformEvent>>::parse(response) {
116 Some(Ok(response)) => match response.validate() {
117 Ok(BitfinexPlatformEvent::PlatformStatus(status)) => {
119 debug!(
120 exchange = %Exchange::ID,
121 %success_responses,
122 %expected_responses,
123 payload = ?status,
124 "received Bitfinex platform status",
125 );
126 continue
127 }
128
129 Ok(BitfinexPlatformEvent::Subscribed(response)) => {
131 let BitfinexSubResponse { channel, market, channel_id } = &response;
133 let subscription_id = ExchangeSub::from((channel, market)).id();
134
135 if let Some(subscription) = instrument_map.0.remove(&subscription_id) {
137 success_responses += 1;
138 unacknowledged.remove(&subscription_id);
139 awaiting_snapshot.insert(channel_id.0);
140 instrument_map
141 .0
142 .insert(SubscriptionId(channel_id.0.to_smolstr()), subscription);
143
144 debug!(
145 exchange = %Exchange::ID,
146 %success_responses,
147 %expected_responses,
148 payload = ?response,
149 "received valid Ok subscription response",
150 );
151 }
152 continue
153 }
154
155 Err(SocketError::Subscribe(message)) => message,
158 Err(error) => error.to_string(),
159
160 Ok(BitfinexPlatformEvent::Error(error)) => format!("{error:?}"),
162 }
163 Some(Err(SocketError::Deserialise { error: _, payload }))
164 if success_responses >= 1 =>
165 {
166 if snapshot_channel(&payload)
170 .is_some_and(|channel_id| awaiting_snapshot.remove(&channel_id))
171 {
172 init_snapshots_received += 1;
173 }
174 buff_active_subscription_events.push(WsMessage::text(payload));
175 continue
176 }
177 Some(Err(SocketError::Terminated(close_frame))) => {
178 format!("received WebSocket CloseFrame: {close_frame}")
179 }
180 _ => {
181 continue
183 }
184 }
185 }
186 };
187
188 break Err(failure_error(
189 failure,
190 unacknowledged,
191 init_snapshots_received,
192 expected_responses,
193 ));
194 }
195}
196
197fn snapshot_channel(payload: &str) -> Option<u32> {
200 let frame = serde_json::from_str::<serde_json::Value>(payload).ok()?;
201 let frame = frame.as_array()?;
202 if frame.get(1).and_then(serde_json::Value::as_str) == Some("hb") {
203 return None;
204 }
205 u32::try_from(frame.first()?.as_u64()?).ok()
206}
207
208fn failure_error(
212 reason: String,
213 unacknowledged: FnvHashSet<SubscriptionId>,
214 init_snapshots_received: usize,
215 expected_responses: usize,
216) -> SocketError {
217 if unacknowledged.is_empty() {
218 return SocketError::Subscribe(format!(
219 "{reason} (every subscription was acknowledged; {init_snapshots_received} of \
220 {expected_responses} initial snapshots had arrived)"
221 ));
222 }
223
224 let mut unacknowledged = unacknowledged.into_iter().collect::<Vec<_>>();
225 unacknowledged.sort_unstable();
226 SocketError::Unacknowledged {
227 reason,
228 unacknowledged,
229 }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)] mod tests {
235 use super::*;
236 use crate::exchange::bitfinex::Bitfinex;
237 use futures::stream;
238
239 const TIMEOUT: Duration = Duration::from_secs(10);
240
241 fn map(ids: &[&str]) -> Map<usize> {
242 ids.iter()
243 .enumerate()
244 .map(|(key, id)| (SubscriptionId::from(*id), key))
245 .collect()
246 }
247
248 fn ids(ids: &[&str]) -> Vec<SubscriptionId> {
249 ids.iter().map(|id| SubscriptionId::from(*id)).collect()
250 }
251
252 fn unacknowledged(error: SocketError) -> (String, Vec<SubscriptionId>) {
253 match error {
254 SocketError::Unacknowledged {
255 reason,
256 unacknowledged,
257 } => (reason, unacknowledged),
258 other => panic!("expected Unacknowledged, got {other:?}"),
259 }
260 }
261
262 fn text(message: &str) -> Result<WsMessage, WsError> {
263 Ok(WsMessage::text(message.to_owned()))
264 }
265
266 fn subscribed(channel: &str, symbol: &str, channel_id: u32) -> Result<WsMessage, WsError> {
267 Ok(WsMessage::text(format!(
268 r#"{{"event":"subscribed","channel":"{channel}","chanId":{channel_id},"symbol":"{symbol}","pair":"X"}}"#
269 )))
270 }
271
272 fn snapshot(channel_id: u32) -> Result<WsMessage, WsError> {
273 Ok(WsMessage::text(format!(
274 "[{channel_id},[[1,1791679984451,0.1,82995]]]"
275 )))
276 }
277
278 const INFO: &str = r#"{"event":"info","version":2,"serverId":"x","platform":{"status":1}}"#;
279
280 #[tokio::test(start_paused = true)]
281 async fn every_acknowledgement_rekeys_its_subscription_by_channel_id() {
282 let mut messages = stream::iter([
283 text(INFO),
284 subscribed("trades", "tBTCUSD", 53),
285 snapshot(53),
286 subscribed("trades", "tETHUSD", 54),
287 snapshot(54),
288 ]);
289
290 let (map, buffered) = validate_responses::<Bitfinex, _, _>(
291 map(&["trades|tBTCUSD", "trades|tETHUSD"]),
292 &mut messages,
293 TIMEOUT,
294 )
295 .await
296 .unwrap();
297
298 assert_eq!(map.find("53").unwrap(), &0);
299 assert_eq!(map.find("54").unwrap(), &1);
300 assert_eq!(buffered.len(), 2);
301 }
302
303 #[tokio::test(start_paused = true)]
304 async fn updates_and_heartbeats_are_kept_but_not_counted_as_snapshots() {
305 let mut messages = stream::iter([
307 subscribed("trades", "tBTCUSD", 53),
308 text(r#"[53,"hb"]"#),
309 snapshot(53),
310 text(r#"[53,"te",[2,1791679999877,0.002,82997]]"#),
311 snapshot(53),
312 subscribed("trades", "tETHUSD", 54),
313 snapshot(54),
314 ]);
315
316 let (map, buffered) = validate_responses::<Bitfinex, _, _>(
317 map(&["trades|tBTCUSD", "trades|tETHUSD"]),
318 &mut messages,
319 TIMEOUT,
320 )
321 .await
322 .unwrap();
323
324 assert_eq!(map.0.len(), 2);
325 assert_eq!(buffered.len(), 5);
326 }
327
328 #[test]
329 fn a_snapshot_frame_names_its_channel_and_a_heartbeat_none() {
330 assert_eq!(snapshot_channel("[53,[[1,2,0.1,3]]]"), Some(53));
331 assert_eq!(snapshot_channel(r#"[53,"te",[1,2,0.1,3]]"#), Some(53));
332 assert_eq!(snapshot_channel(r#"[53,"hb"]"#), None);
333 assert_eq!(snapshot_channel(r#"{"event":"pong"}"#), None);
334 }
335
336 #[tokio::test(start_paused = true)]
337 async fn a_refusal_names_the_subscriptions_left_unacknowledged() {
338 let mut messages = stream::iter([
340 text(INFO),
341 subscribed("trades", "tBTCUSD", 53),
342 text(
343 r#"{"channel":"trades","symbol":"tNOSUCH","event":"error","msg":"symbol: invalid","code":10300,"pair":"NOSUCH"}"#,
344 ),
345 ]);
346
347 let error = validate_responses::<Bitfinex, _, _>(
348 map(&["trades|tBTCUSD", "trades|tNOSUCH", "trades|tETHUSD"]),
349 &mut messages,
350 TIMEOUT,
351 )
352 .await
353 .unwrap_err();
354
355 let (reason, unacknowledged) = unacknowledged(error);
356 assert_eq!(
357 reason,
358 "Bitfinex refused trades tNOSUCH: symbol: invalid (code 10300)"
359 );
360 assert_eq!(unacknowledged, ids(&["trades|tETHUSD", "trades|tNOSUCH"]));
361 }
362
363 #[tokio::test(start_paused = true)]
364 async fn a_dropped_connection_names_the_subscriptions_left_unacknowledged() {
365 let mut messages = stream::iter([subscribed("trades", "tBTCUSD", 53)]);
366
367 let error = validate_responses::<Bitfinex, _, _>(
368 map(&["trades|tBTCUSD", "book|tETHUSD"]),
369 &mut messages,
370 TIMEOUT,
371 )
372 .await
373 .unwrap_err();
374
375 let (reason, unacknowledged) = unacknowledged(error);
376 assert_eq!(reason, "WebSocket stream terminated unexpectedly");
377 assert_eq!(unacknowledged, ids(&["book|tETHUSD"]));
378 }
379
380 #[tokio::test(start_paused = true)]
381 async fn the_timeout_fires_while_an_acknowledged_subscription_keeps_streaming() {
382 let heartbeats = stream::unfold((), |()| async {
384 tokio::time::sleep(Duration::from_secs(1)).await;
385 Some((text(r#"[53,"hb"]"#), ()))
386 });
387 let mut messages =
388 Box::pin(stream::iter([subscribed("trades", "tBTCUSD", 53)]).chain(heartbeats));
389 let started = tokio::time::Instant::now();
390
391 let error = validate_responses::<Bitfinex, _, _>(
392 map(&["trades|tBTCUSD", "trades|tETHUSD"]),
393 &mut messages,
394 TIMEOUT,
395 )
396 .await
397 .unwrap_err();
398
399 assert_eq!(started.elapsed(), TIMEOUT);
400 let (reason, unacknowledged) = unacknowledged(error);
401 assert_eq!(
402 reason,
403 format!("subscription validation timeout reached: {TIMEOUT:?}")
404 );
405 assert_eq!(unacknowledged, ids(&["trades|tETHUSD"]));
406 }
407
408 #[tokio::test(start_paused = true)]
409 async fn a_missing_snapshot_after_every_acknowledgement_says_so() {
410 let mut messages = stream::iter([
411 subscribed("trades", "tBTCUSD", 53),
412 subscribed("trades", "tETHUSD", 54),
413 snapshot(53),
414 ]);
415
416 let error = validate_responses::<Bitfinex, _, _>(
417 map(&["trades|tBTCUSD", "trades|tETHUSD"]),
418 &mut messages,
419 TIMEOUT,
420 )
421 .await
422 .unwrap_err();
423
424 let SocketError::Subscribe(message) = error else {
425 panic!("expected Subscribe, got {error:?}");
426 };
427 assert_eq!(
428 message,
429 "WebSocket stream terminated unexpectedly (every subscription was acknowledged; 1 of \
430 2 initial snapshots had arrived)"
431 );
432 }
433}