brokapi_bcs_protocol/ws/session.rs
1//! The background WebSocket session: connect, heartbeat, reconnect and restore subscriptions.
2
3use crate::error::BcsApiError;
4use crate::http::client::BcsHttpClient;
5use crate::models::messages::IncomingMessage;
6use crate::ws::client::BcsWsChannel;
7use crate::ws::models::MarketDataSubscribe;
8use crate::ws::models::WsOutgoing;
9use crate::ws::parser::parse_incoming;
10use futures_util::{SinkExt, StreamExt};
11use kanal::{AsyncReceiver, AsyncSender};
12use std::collections::HashSet;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::Duration;
16use tokio::net::TcpStream;
17use tokio::sync::{RwLock, watch};
18use tokio::time::{self, sleep};
19use tokio_tungstenite::connect_async;
20use tokio_tungstenite::tungstenite::Message;
21use tokio_tungstenite::tungstenite::client::IntoClientRequest;
22use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
23use tracing::{debug, error, info, warn};
24
25type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
26
27const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
28/// Default pause before the first reconnect attempt: 500 milliseconds (the same as in the T-Bank and
29/// Finam connectors).
30pub const DEFAULT_RECONNECT_MIN: Duration = Duration::from_millis(500);
31/// Default upper bound of the pause between reconnect attempts: 30 seconds.
32pub const DEFAULT_RECONNECT_MAX: Duration = Duration::from_secs(30);
33/// A connection that lived this long is healthy: the pause goes back to the minimum.
34const WS_HEALTHY_AFTER: Duration = Duration::from_secs(10);
35/// How long to wait for a pong or ping before the link is dropped (twice the heartbeat interval).
36const WS_PONG_TIMEOUT: Duration = Duration::from_secs(60);
37/// The most subscribe commands one session keeps.
38const WS_MAX_SUBSCRIPTIONS: usize = 100;
39
40/// A command on its way from the caller to the session task.
41///
42/// `seq` is set for a subscribe command: it is the number the command has in the restore list. When the
43/// session connects, it sends the whole list, and a queued subscribe whose number it has just sent is
44/// dropped, so the broker gets every subscribe once.
45struct Queued {
46 seq: Option<u64>,
47 outgoing: WsOutgoing,
48}
49
50/// Options of a WebSocket session.
51///
52/// The default is no announcement, a first pause of 500 ms and an upper bound of 30 s.
53///
54/// # Examples
55///
56/// ```
57/// use std::time::Duration;
58/// use brokapi_bcs_protocol::ws::session::WsOptions;
59///
60/// let options = WsOptions { announce_connects: true, ..WsOptions::default() };
61/// assert_eq!(options.reconnect_min, Duration::from_millis(500));
62/// assert_eq!(options.reconnect_max, Duration::from_secs(30));
63/// ```
64#[derive(Debug, Clone, Copy)]
65pub struct WsOptions {
66 /// Send [`IncomingMessage::Reconnected`] to the reader at every connect, before the subscriptions
67 /// are restored (also at the first connect).
68 ///
69 /// That is how the reader learns about a lost link in the same stream as the data. Count the
70 /// markers: from the second on, the connection was lost, and what came before is not continued by
71 /// what follows. For order events this means: ask for the state of your open orders again.
72 pub announce_connects: bool,
73 /// Pause before the first reconnect attempt. It doubles after each failed attempt up to
74 /// `reconnect_max`. Values below 10 ms are raised to 10 ms.
75 pub reconnect_min: Duration,
76 /// Upper bound of the pause. The pause returns to `reconnect_min` once a connection has lived
77 /// 10 seconds. A value below `reconnect_min` is raised to it.
78 pub reconnect_max: Duration,
79}
80
81impl Default for WsOptions {
82 fn default() -> Self {
83 Self {
84 announce_connects: false,
85 reconnect_min: DEFAULT_RECONNECT_MIN,
86 reconnect_max: DEFAULT_RECONNECT_MAX,
87 }
88 }
89}
90
91/// A background WebSocket session for one channel. It reconnects by itself.
92///
93/// What the session does for you:
94///
95/// * It connects with a bearer token and takes a fresh token at every reconnect. If the broker refuses
96/// the connection with 403 or "Unauthorized", it forces a token refresh.
97/// * It sends a ping every 30 seconds and answers the broker's pings. If no pong or ping arrives for
98/// 60 seconds it drops the link and reconnects.
99/// * After a lost link it waits (500 ms, doubling up to 30 s, see [`WsOptions`]) and connects again.
100/// Market data subscriptions are sent again after each connect.
101/// * It parses every text frame and pushes the [`IncomingMessage`] with `try_send` into your channel.
102/// **If your channel is full, the frame is dropped** and counted ([`WsSession::dropped_events`]):
103/// the session never waits for a slow reader. Size the channel for bursts and read it quickly.
104/// * It stops when your channel is closed (the receiver dropped) or when the session value is dropped.
105///
106/// There is **no explicit close method**. Dropping the `WsSession` stops its background task at once,
107/// also when the session is waiting for the next reconnect or has never been able to connect.
108///
109/// The session never reports a lost link as an error value. Use [`WsSession::is_connected`] and, for
110/// an ordered signal, [`WsOptions::announce_connects`].
111///
112/// # Examples
113///
114/// ```
115/// # #[tokio::main(flavor = "current_thread")]
116/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
117/// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
118/// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
119/// use std::time::Duration;
120/// use brokapi_bcs_protocol::http::client::BcsHttpClient;
121/// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
122/// use brokapi_bcs_protocol::models::messages::IncomingMessage;
123/// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
124/// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
125/// use brokapi_bcs_protocol::ws::session::{WsOptions, WsSession};
126///
127/// let http = BcsHttpClient::new(cfg)?;
128/// let (tx, rx) = kanal::bounded_async::<IncomingMessage>(64);
129/// let options = WsOptions { announce_connects: true, ..WsOptions::default() };
130/// let session = WsSession::spawn_with(BcsWsChannel::MarketData, http, tx, options);
131///
132/// let sub = MarketDataSubscribe::order_book(vec![InstrumentKey::new("SBER", "TQBR")], 5);
133/// session.subscribe(sub).await?;
134///
135/// // First the connect marker, then the answers of the broker.
136/// let first = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await??;
137/// assert!(matches!(first, IncomingMessage::Reconnected));
138/// assert_eq!(session.dropped_events(), 0);
139/// # Ok(())
140/// # }
141/// ```
142pub struct WsSession {
143 /// The channel of this session.
144 channel: BcsWsChannel,
145 /// Queue of outgoing commands.
146 user_tx: AsyncSender<Queued>,
147 /// Subscriptions of this session with their numbers, sent again after each reconnect.
148 subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>>,
149 /// The number of the next subscribe command.
150 next_seq: AtomicU64,
151 /// Set while the link is up.
152 is_connected: Arc<watch::Sender<bool>>,
153 /// Frames dropped because the downstream channel was full.
154 dropped_events: Arc<AtomicU64>,
155 /// Handle of the background task.
156 _handle: tokio::task::JoinHandle<()>,
157}
158
159impl Drop for WsSession {
160 /// Stops the background task, wherever it is: connected, waiting for a reconnect or never connected.
161 fn drop(&mut self) {
162 self._handle.abort();
163 }
164}
165
166impl WsSession {
167 /// Starts a session with the default [`WsOptions`]. It returns at once, the connection is made in
168 /// the background.
169 ///
170 /// # Panics
171 ///
172 /// Panics if there is no Tokio runtime, because the session is a Tokio task.
173 ///
174 /// # Examples
175 ///
176 /// ```
177 /// # #[tokio::main(flavor = "current_thread")]
178 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
179 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
180 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
181 /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
182 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
183 /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
184 /// use brokapi_bcs_protocol::ws::session::WsSession;
185 ///
186 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
187 /// let session = WsSession::spawn(BcsWsChannel::Limits, BcsHttpClient::new(cfg)?, tx);
188 /// assert_eq!(session.channel(), BcsWsChannel::Limits);
189 /// # Ok(())
190 /// # }
191 /// ```
192 pub fn spawn(
193 channel: BcsWsChannel,
194 http_client: BcsHttpClient,
195 event_tx: AsyncSender<IncomingMessage>,
196 ) -> Self {
197 Self::spawn_with(channel, http_client, event_tx, WsOptions::default())
198 }
199
200 /// Starts a session with the given options. Otherwise the same as [`WsSession::spawn`].
201 ///
202 /// # Panics
203 ///
204 /// Panics if there is no Tokio runtime.
205 ///
206 /// # Examples
207 ///
208 /// See the example on [`WsSession`].
209 pub fn spawn_with(
210 channel: BcsWsChannel,
211 http_client: BcsHttpClient,
212 event_tx: AsyncSender<IncomingMessage>,
213 options: WsOptions,
214 ) -> Self {
215 let (user_tx, user_rx) = kanal::bounded_async::<Queued>(128);
216 let user_rx = Arc::new(user_rx);
217 let subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>> = Arc::new(RwLock::new(Vec::new()));
218
219 let is_connected = Arc::new(watch::channel(false).0);
220 let dropped_events = Arc::new(AtomicU64::new(0));
221
222 let _handle = tokio::spawn(Self::run_loop(
223 channel,
224 http_client,
225 event_tx,
226 options,
227 subscriptions.clone(),
228 user_rx,
229 is_connected.clone(),
230 dropped_events.clone(),
231 ));
232
233 Self {
234 channel,
235 user_tx,
236 subscriptions,
237 next_seq: AtomicU64::new(0),
238 is_connected,
239 dropped_events,
240 _handle,
241 }
242 }
243
244 /// The number of parsed frames dropped because your event channel was full.
245 ///
246 /// A growing number means the reader is too slow or the channel is too small. Frames that were
247 /// dropped are gone. For order events that means states you must re-read over REST.
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// # #[tokio::main(flavor = "current_thread")]
253 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
254 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
255 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
256 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
257 /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
258 ///
259 /// let ws = BcsWsClient::new(cfg)?;
260 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
261 /// let session = ws.connect_limits(tx);
262 /// assert_eq!(session.dropped_events(), 0);
263 /// # Ok(())
264 /// # }
265 /// ```
266 pub fn dropped_events(&self) -> u64 {
267 self.dropped_events.load(Ordering::Relaxed)
268 }
269
270 /// `true` while the link is up and the subscriptions have been sent again.
271 ///
272 /// It is `false` before the first connect, during a reconnect and after the session has ended.
273 /// Do not use it to decide that data is flowing, it only says the socket is open.
274 ///
275 /// # Examples
276 ///
277 /// ```
278 /// # #[tokio::main(flavor = "current_thread")]
279 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
280 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
281 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
282 /// use std::time::Duration;
283 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
284 /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
285 ///
286 /// let ws = BcsWsClient::new(cfg)?;
287 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
288 /// let session = ws.connect_limits(tx);
289 /// tokio::time::timeout(Duration::from_secs(5), async {
290 /// while !session.is_connected() {
291 /// tokio::time::sleep(Duration::from_millis(10)).await;
292 /// }
293 /// })
294 /// .await?;
295 /// # Ok(())
296 /// # }
297 /// ```
298 pub fn is_connected(&self) -> bool {
299 *self.is_connected.borrow()
300 }
301
302 /// Waits until the link is up and the subscriptions have been sent again ([`is_connected`](Self::is_connected)
303 /// is `true`). Returns at once when it already is.
304 ///
305 /// It has no limit of its own: it waits for a connect that may never come (the broker is unreachable). Put
306 /// your own timeout around it, as [`tokio::time::timeout`]. The session keeps trying in the background
307 /// either way. A session that has ended for good never comes up, so the wait then ends only with your timeout.
308 ///
309 /// # Examples
310 ///
311 /// ```
312 /// # #[tokio::main(flavor = "current_thread")]
313 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
314 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
315 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
316 /// use std::time::Duration;
317 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
318 /// use brokapi_bcs_protocol::ws::client::BcsWsClient;
319 ///
320 /// let ws = BcsWsClient::new(cfg)?;
321 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
322 /// let session = ws.connect_limits(tx);
323 /// tokio::time::timeout(Duration::from_secs(5), session.wait_connected()).await?;
324 /// assert!(session.is_connected());
325 /// # Ok(())
326 /// # }
327 /// ```
328 pub async fn wait_connected(&self) {
329 let mut up = self.is_connected.subscribe();
330 // The sender lives as long as this value does, so the wait cannot end in an error.
331 let _ = up.wait_for(|connected| *connected).await;
332 }
333
334 /// The channel this session is on.
335 pub fn channel(&self) -> BcsWsChannel {
336 self.channel
337 }
338
339 /// Subscribes on the market data channel. The command is kept and sent again after each reconnect.
340 ///
341 /// The command is put in the restore list first and then queued for sending. Something to know:
342 ///
343 /// * The session counts **commands**: at most 100 per session. The broker's limit (broker docs) is
344 /// 100 **instruments** per connection, so a command with many instruments can pass the local
345 /// check and still be refused by the broker (as an error frame, not as a return value).
346 /// * If you subscribe before the first connect, the command waits in the restore list and goes out
347 /// once, when the session connects. The same holds for a command given while the session is
348 /// connecting.
349 ///
350 /// The answer of the broker (subscription confirmation, data, or an error frame) arrives in your
351 /// event channel, not as the return value.
352 ///
353 /// # Errors
354 ///
355 /// [`BcsApiError::SubscriptionError`] if the session is not on [`BcsWsChannel::MarketData`], if it
356 /// already holds 100 subscribe commands, or if the session task has ended.
357 ///
358 /// # Examples
359 ///
360 /// ```
361 /// # #[tokio::main(flavor = "current_thread")]
362 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
363 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
364 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
365 /// use brokapi_bcs_protocol::error::BcsApiError;
366 /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
367 /// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
368 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
369 /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
370 /// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
371 /// use brokapi_bcs_protocol::ws::session::WsSession;
372 ///
373 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
374 /// let sber = vec![InstrumentKey::new("SBER", "TQBR")];
375 ///
376 /// // A portfolio session takes no subscriptions.
377 /// let portfolio = WsSession::spawn(BcsWsChannel::Portfolio, BcsHttpClient::new(cfg.clone())?, tx.clone());
378 /// let err = portfolio.subscribe(MarketDataSubscribe::quotes(sber.clone())).await.unwrap_err();
379 /// assert!(matches!(err, BcsApiError::SubscriptionError { .. }));
380 ///
381 /// // A market data session does.
382 /// let md = WsSession::spawn(BcsWsChannel::MarketData, BcsHttpClient::new(cfg)?, tx);
383 /// md.subscribe(MarketDataSubscribe::quotes(sber)).await?;
384 /// # Ok(())
385 /// # }
386 /// ```
387 pub async fn subscribe(&self, sub: MarketDataSubscribe) -> Result<(), BcsApiError> {
388 if self.channel != BcsWsChannel::MarketData {
389 return Err(BcsApiError::SubscriptionError {
390 message: format!(
391 "WS {:?} ignoring subscribe message for non-MarketData channel",
392 self.channel
393 ),
394 });
395 }
396 let mut subs = self.subscriptions.write().await;
397 if subs.len() >= WS_MAX_SUBSCRIPTIONS {
398 return Err(BcsApiError::SubscriptionError {
399 message: format!(
400 "Subscription limit reached ({} max per session)",
401 WS_MAX_SUBSCRIPTIONS
402 ),
403 });
404 }
405
406 let seq = self.next_seq.fetch_add(1, Ordering::Relaxed);
407 subs.push((seq, WsOutgoing::Subscribe(sub.clone())));
408 drop(subs);
409
410 self.user_tx
411 .send(Queued {
412 seq: Some(seq),
413 outgoing: WsOutgoing::Subscribe(sub),
414 })
415 .await
416 .map_err(|e| BcsApiError::SubscriptionError {
417 message: format!("Failed to send subscription: {}", e),
418 })?;
419
420 Ok(())
421 }
422
423 /// Unsubscribes on the market data channel. Pass the same [`MarketDataSubscribe`] you subscribed
424 /// with: the session turns it into the unsubscribe command itself.
425 ///
426 /// The matching entry is removed from the restore list only if it is **equal** to the one you pass
427 /// (same instruments in the same order, same depth, same time frame). A different grouping of the
428 /// same instruments is sent to the broker as an unsubscribe, but the original stays in the restore
429 /// list and comes back after a reconnect. The broker docs describe no answer to an unsubscribe.
430 ///
431 /// # Errors
432 ///
433 /// [`BcsApiError::SubscriptionError`] if the session is not on [`BcsWsChannel::MarketData`] or if
434 /// the session task has ended.
435 ///
436 /// # Examples
437 ///
438 /// ```
439 /// # #[tokio::main(flavor = "current_thread")]
440 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
441 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
442 /// # let cfg = brokapi_bcs_protocol::config::BcsConfig::for_mock("mock-refresh-token", &server.http_base);
443 /// use brokapi_bcs_protocol::http::client::BcsHttpClient;
444 /// use brokapi_bcs_protocol::models::instrument::InstrumentKey;
445 /// use brokapi_bcs_protocol::models::messages::IncomingMessage;
446 /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
447 /// use brokapi_bcs_protocol::ws::models::MarketDataSubscribe;
448 /// use brokapi_bcs_protocol::ws::session::WsSession;
449 ///
450 /// let (tx, _rx) = kanal::bounded_async::<IncomingMessage>(16);
451 /// let md = WsSession::spawn(BcsWsChannel::MarketData, BcsHttpClient::new(cfg)?, tx);
452 ///
453 /// let sub = MarketDataSubscribe::order_book(vec![InstrumentKey::new("SBER", "TQBR")], 5);
454 /// md.subscribe(sub.clone()).await?;
455 /// md.unsubscribe(sub).await?; // the same value as at subscribe
456 /// # Ok(())
457 /// # }
458 /// ```
459 pub async fn unsubscribe(&self, sub: MarketDataSubscribe) -> Result<(), BcsApiError> {
460 if self.channel != BcsWsChannel::MarketData {
461 return Err(BcsApiError::SubscriptionError {
462 message: format!(
463 "WS {:?} ignoring unsubscribe message for non-MarketData channel",
464 self.channel
465 ),
466 });
467 }
468
469 {
470 let mut subs = self.subscriptions.write().await;
471 subs.retain(|(_, s)| s != &WsOutgoing::Subscribe(sub.clone()));
472 }
473
474 self.user_tx
475 .send(Queued {
476 seq: None,
477 outgoing: WsOutgoing::Unsubscribe(sub.into_unsubscribe()),
478 })
479 .await
480 .map_err(|e| BcsApiError::SubscriptionError {
481 message: format!("Failed to send unsubscription: {}", e),
482 })
483 }
484
485 /// The main loop: connect, read and write, reconnect.
486 #[allow(clippy::too_many_arguments)]
487 async fn run_loop(
488 channel: BcsWsChannel,
489 http_client: BcsHttpClient,
490 event_tx: AsyncSender<IncomingMessage>,
491 options: WsOptions,
492 subscriptions: Arc<RwLock<Vec<(u64, WsOutgoing)>>>,
493 user_rx: Arc<AsyncReceiver<Queued>>,
494 is_connected: Arc<watch::Sender<bool>>,
495 dropped_events: Arc<AtomicU64>,
496 ) {
497 let ws_base = http_client.ws_base_url().to_string();
498 let backoff_min = options.reconnect_min.max(Duration::from_millis(10));
499 let backoff_max = options.reconnect_max.max(backoff_min);
500 let mut backoff = backoff_min;
501
502 'session: loop {
503 let url = format!("{}{}", ws_base, channel.path());
504 // 1. Свежий токен при каждом reconnect
505 let token = match http_client.get_access_token().await {
506 Ok(t) => t,
507 Err(e) => {
508 error!("Failed to get token for {:?}: {}", channel, e);
509 sleep(backoff).await;
510 backoff = (backoff * 2).min(backoff_max);
511 continue;
512 }
513 };
514
515 // 2. Подключение с обработкой 403
516 let ws_stream = match Self::connect(&url, &token).await {
517 Ok(s) => s,
518 Err(e) => {
519 // Если 403 - принудительно обновляем токен и увеличиваем backoff
520 if e.to_string().contains("403 Forbidden")
521 || e.to_string().contains("Unauthorized")
522 {
523 error!("WS {:?} 403 Forbidden - forcing token refresh", channel);
524 let _ = http_client.refresh_access_token().await;
525 }
526
527 error!("WS {:?} connect failed: {}", channel, e);
528 sleep(backoff).await;
529 backoff = (backoff * 2).min(backoff_max);
530 continue;
531 }
532 };
533
534 info!("WS {:?} session established", channel);
535 let connected_at = tokio::time::Instant::now();
536 let (mut write, mut read) = ws_stream.split();
537
538 // 3. Pong watchdog state
539 // On the clock of tokio, like the heartbeat itself: a test moves both with one `jump`.
540 let mut last_pong = time::Instant::now();
541
542 // Reader is told first: the data of the restored subscriptions comes after this marker.
543 if options.announce_connects
544 && event_tx.send(IncomingMessage::Reconnected).await.is_err()
545 {
546 info!("Event channel closed for {:?}", channel);
547 break 'session;
548 }
549
550 // 4. Восстановить подписки. Номера отправленных запоминаем: такая же команда из очереди уже
551 // ушла, второй раз ее не шлем.
552 let mut restored: HashSet<u64> = HashSet::new();
553 {
554 let subs = subscriptions.read().await;
555 for (seq, outgoing) in subs.iter() {
556 match serde_json::to_string(outgoing) {
557 Ok(json) => {
558 if write.send(Message::Text(json.into())).await.is_err() {
559 error!("Failed to restore subscription for {:?}", channel);
560 continue 'session;
561 }
562 restored.insert(*seq);
563 }
564 Err(e) => {
565 error!("Failed to serialize subscription for {:?}: {}", channel, e);
566 }
567 }
568 }
569 if !subs.is_empty() {
570 info!("Restored {} subscriptions for {:?}", subs.len(), channel);
571 }
572 }
573
574 is_connected.send_replace(true); // ← UP: подписки восстановлены, готов к работе
575
576 // 5. Единый event loop: read + write + heartbeat в одном select
577 let mut heartbeat = time::interval(WS_HEARTBEAT_INTERVAL);
578 heartbeat.tick().await; // пропустить немедленный первый тик
579
580 'inner_session: loop {
581 tokio::select! {
582 // Входящие сообщения из WS
583 frame = read.next() => {
584 match frame {
585 Some(Ok(Message::Text(text))) => {
586 let incoming = parse_incoming(&text, channel);
587 match event_tx.try_send(incoming) {
588 Ok(true) => {}
589 Ok(false) => {
590 dropped_events.fetch_add(1, Ordering::Relaxed);
591 warn!(
592 "WS {:?} downstream full — dropping parsed frame",
593 channel
594 );
595 }
596 Err(_) => {
597 info!("Event channel closed for {:?}", channel);
598 break 'session;
599 }
600 }
601 }
602 Some(Ok(Message::Close(_))) => {
603 info!("WS {:?} closed by server", channel);
604 break 'inner_session;
605 }
606 Some(Ok(Message::Pong(_))) => {
607 last_pong = time::Instant::now();
608 info!("WS {:?} received pong", channel);
609 }
610 Some(Ok(Message::Ping(data))) => {
611 last_pong = time::Instant::now();
612 if write.send(Message::Pong(data)).await.is_err() {
613 error!("WS {:?} failed to send pong", channel);
614 break 'inner_session;
615 }
616 }
617 Some(Ok(_)) => {
618 warn!("WS {:?} received unknown message", channel);
619 }
620 Some(Err(e)) => {
621 error!("WS {:?} read error: {}", channel, e);
622 break 'inner_session;
623 }
624 None => {
625 info!("WS {:?} stream ended", channel);
626 break 'inner_session;
627 }
628 }
629 }
630
631 // ИСПРАВЛЕННЫЙ HEARTBEAT - настоящий Ping frame!
632 _ = heartbeat.tick() => {
633 let elapsed = last_pong.elapsed();
634 if elapsed > WS_PONG_TIMEOUT {
635 error!("Pong timeout for {:?} ({:?}), reconnect", channel, elapsed);
636 break 'inner_session;
637 }
638
639
640 if write.send(Message::Ping(vec![].into())).await.is_err() {
641 error!("Failed to send Ping frame");
642 break 'inner_session;
643 }
644 debug!("WS {:?} → Ping frame sent", channel);
645 }
646
647 // Исходящие сообщения от юзера (подписки и т.д.)
648 msg = user_rx.recv() => {
649 match msg {
650 Ok(Queued { seq, outgoing }) => {
651 // Фильтрация: Subscribe/Unsubscribe только для MarketData
652 if channel != BcsWsChannel::MarketData {
653 warn!("WS {:?} ignoring subscribe message for non-MarketData channel", channel);
654 continue 'inner_session;
655 }
656 // Эта подписка уже ушла вместе со списком при подключении.
657 if seq.is_some_and(|n| restored.remove(&n)) {
658 continue 'inner_session;
659 }
660 match serde_json::to_string(&outgoing) {
661 Ok(json) => {
662 if write.send(Message::Text(json.into())).await.is_err() {
663 error!("WS {:?} write error", channel);
664 break 'inner_session;
665 }
666 }
667 Err(e) => {
668 error!("WS {:?} serialize error: {}", channel, e);
669 }
670 }
671 }
672 Err(_) => break 'session,
673 }
674 }
675
676
677 }
678 }
679
680 is_connected.send_replace(false); // ← DOWN (при любом выходе из inner)
681 // Соединение, которое тут же рвется, не должно давить на сервер с минимальной паузой.
682 if connected_at.elapsed() >= WS_HEALTHY_AFTER {
683 backoff = backoff_min;
684 }
685 warn!(
686 "WS {:?} disconnected, reconnecting in {:?}...",
687 channel, backoff
688 );
689 sleep(backoff).await;
690 backoff = (backoff * 2).min(backoff_max);
691 }
692 is_connected.send_replace(false); // ← DOWN (выход из session loop)
693 }
694
695 async fn connect(url: &str, access_token: &str) -> Result<WsStream, BcsApiError> {
696 let mut request = url
697 .into_client_request()
698 .map_err(|e| BcsApiError::WsError {
699 code: None,
700 message: format!("Failed to create WS request: {}", e),
701 })?;
702
703 request.headers_mut().insert(
704 "Authorization",
705 format!("Bearer {}", access_token)
706 .parse()
707 .map_err(|e| BcsApiError::WsError {
708 code: None,
709 message: format!("Invalid auth header: {}", e),
710 })?,
711 );
712
713 let (stream, res) = connect_async(request)
714 .await
715 .map_err(|e| BcsApiError::WsError {
716 code: None,
717 message: format!("WebSocket connection failed internally: {}", e),
718 })?;
719
720 info!(
721 "WebSocket connected to {} with status <{}>",
722 url,
723 res.status()
724 );
725 Ok(stream)
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn default_pause_is_the_range_of_the_other_connectors() {
735 let o = WsOptions::default();
736 assert_eq!(
737 (o.reconnect_min, o.reconnect_max),
738 (Duration::from_millis(500), Duration::from_secs(30))
739 );
740 assert!(!o.announce_connects);
741 }
742}