Skip to main content

polyoxide_binance/spot/ws/
supervised.rs

1//! The supervised tier: the Binance protocol on the kit's `Supervisor`, with
2//! keep-alive on the wall clock, staleness detection, reconnect with a paced
3//! replay of the streams, rotation before Binance's 24-hour cutoff and outage
4//! markers. The protocol, the router and the forwarding are `shared::ws`'s.
5
6use std::{
7    fmt,
8    pin::Pin,
9    sync::Arc,
10    task::{Context, Poll},
11};
12
13use futures_util::Stream;
14use polyoxide_rt::Runtime;
15use polyoxide_ws::{SocketTransport, Timing};
16
17use crate::{
18    shared::ws::{
19        supervised::{connect as connect_running, Handle, Running, Settings},
20        DEFAULT_MAX_CONNECTION_AGE,
21    },
22    spot::ws::{
23        error::SpotWsError, event::Update, protocol::SpotStreams, stream::StreamName, StreamPath,
24        SPOT_WS_BASE,
25    },
26};
27
28/// What the supervised stream yields.
29#[derive(Debug)]
30#[non_exhaustive]
31pub enum Event {
32    /// One frame of one stream, boxed, as the other variants are small.
33    Update(Box<Update>),
34    /// The connection was lost. Every update is stale until the matching
35    /// [`Event::Reconnected`], which always follows while the client runs.
36    Disconnected {
37        /// The path that went down: spot has one, [`StreamPath::Stream`].
38        path: StreamPath,
39        /// Why.
40        reason: DisconnectReason,
41    },
42    /// The path is current again: a fresh connection has replayed its
43    /// streams, or its last stream left during the outage, so nothing on it is
44    /// stale. Sent once per outage, however many attempts it took. Drop book
45    /// state and resync.
46    Reconnected {
47        /// The path that came back.
48        path: StreamPath,
49    },
50}
51
52/// Why the connection went down.
53#[derive(Debug)]
54#[non_exhaustive]
55pub enum DisconnectReason {
56    /// The server closed it, with the code and reason it sent, or ended the
57    /// stream with neither.
58    Closed {
59        /// The close code.
60        code: Option<u16>,
61        /// The close reason.
62        reason: String,
63    },
64    /// A transport error, a replay left unanswered for the staleness window,
65    /// or a failed first connect of a path opened by a membership change.
66    Error(SpotWsError),
67    /// Nothing arrived, pongs included, for the staleness window, or a
68    /// membership change went unanswered for it.
69    Stale,
70    /// Replaced on purpose before Binance's 24-hour cutoff.
71    Rotation,
72}
73
74impl fmt::Display for DisconnectReason {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Closed { code, reason } => match code {
78                Some(code) => write!(f, "closed by the server ({code} {reason})"),
79                None => f.write_str("closed by the server"),
80            },
81            Self::Error(err) => write!(f, "{err}"),
82            Self::Stale => f.write_str("no frame within the staleness window"),
83            Self::Rotation => f.write_str("rotated before the 24-hour cutoff"),
84        }
85    }
86}
87
88/// Builder for a supervised connection.
89///
90/// The connection runs on its own task, on the socket kit's `Supervisor`, and
91/// logs under the `polyoxide_ws` target.
92///
93/// While the consumer leaves 1024 events unread, the connection stops reading
94/// but keeps pinging, and that wait is not silence: a slow consumer is never
95/// reported [`Stale`](DisconnectReason::Stale), and no event is dropped. The
96/// server's own pings go unanswered meanwhile. Binance closes a connection
97/// that has not answered them for a minute; that close is reconnected like any
98/// other, and the events already queued are kept.
99#[derive(Debug, Clone)]
100pub struct SpotWsBuilder {
101    base_url: String,
102    timing: Timing,
103    streams: Vec<StreamName>,
104    runtime: Option<Arc<dyn Runtime>>,
105    socket_transport: Option<Arc<dyn SocketTransport>>,
106}
107
108impl Default for SpotWsBuilder {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl SpotWsBuilder {
115    /// Defaults: the production host, a ping every 20 s (the server's own
116    /// cadence), stale after 30 s of silence, reconnect backoff from 500 ms
117    /// doubling to 60 s, a 10 s connect timeout, rotation after 23 h 50 min,
118    /// and no streams.
119    pub fn new() -> Self {
120        let mut timing = Timing::default();
121        timing.max_connection_age = Some(DEFAULT_MAX_CONNECTION_AGE);
122        Self {
123            base_url: SPOT_WS_BASE.to_owned(),
124            timing,
125            streams: Vec::new(),
126            runtime: None,
127            socket_transport: None,
128        }
129    }
130
131    /// Connect to another host, such as a local test server. The client
132    /// appends `/stream`.
133    pub fn base_url(mut self, url: impl Into<String>) -> Self {
134        self.base_url = url.into();
135        self
136    }
137
138    polyoxide_ws::timing_setters! {
139        /// How often the connection sends a protocol ping, whatever its
140        /// traffic, and also while the consumer leaves 1024 events unread.
141        /// None goes out while a membership change is in flight, which
142        /// `stale_after` bounds. Binance closes a connection that receives
143        /// more than 5 messages a second, requests, pings and pongs counted.
144        ping_interval;
145        /// Silence after which the connection is presumed dead and replaced.
146        /// Pongs and the server's pings count, so a quiet connection that
147        /// answers stays up, and time the consumer leaves 1024 events unread
148        /// does not count.
149        ///
150        /// Also bounds each replay and each membership change as a whole, its
151        /// 500 ms pacing included: one the server leaves unanswered that long
152        /// replaces the connection. A replay is one request, whatever the
153        /// number of streams, since a request carries up to 1024 names.
154        stale_after;
155        /// Reconnect delay schedule: `initial`, doubling to `max`, with no cap
156        /// on attempts, and back to `initial` after a connection that
157        /// delivered an update. Both are raised to at least 1 ms, and an
158        /// `initial` above `max` is lowered to `max`. A reconnect refused with
159        /// a `Retry-After` waits at least that long, up to `max` (DRIFT R11).
160        backoff;
161        /// How long one connection attempt may take to open.
162        connect_timeout;
163        /// How long a connection may live before it is replaced, without
164        /// backoff, ahead of Binance's 24-hour cutoff. Each replacement is a
165        /// new connection, and Binance allows 300 connection attempts per 5
166        /// minutes per IP.
167        max_connection_age;
168    }
169
170    polyoxide_ws::transport_setters! {
171        /// The runtime the paths' supervision tasks, the router and its
172        /// forwarders run on. By default the target's.
173        runtime;
174        /// How each path's connection dials. By default the target's.
175        socket_transport;
176    }
177
178    /// The streams to open with.
179    pub fn streams(mut self, streams: impl IntoIterator<Item = StreamName>) -> Self {
180        self.streams = streams.into_iter().collect();
181        self
182    }
183
184    /// Open the connection, subscribe, and start supervising.
185    ///
186    /// Fails if the first connect fails, or if the streams pass 1024: a setup
187    /// that is wrong is reported, not retried. With no streams it opens
188    /// nothing until the first [`MembershipHandle::subscribe`].
189    pub async fn connect(self) -> Result<SupervisedSpotWs, SpotWsError> {
190        let running = connect_running::<SpotStreams>(Settings {
191            base_url: self.base_url,
192            timing: self.timing,
193            streams: self.streams,
194            runtime: self.runtime,
195            socket_transport: self.socket_transport,
196        })
197        .await?;
198        Ok(SupervisedSpotWs { running })
199    }
200}
201
202/// Changes the streams of a running [`SupervisedSpotWs`].
203///
204/// The connection is opened for the first stream and closed when the last
205/// leaves. The membership is a set: subscribing a stream twice holds it once,
206/// and counting references is the caller's job.
207///
208/// A change is recorded before it is sent. During an outage it is answered
209/// `Ok` at once, and the replay applies it; so is a change whose connection
210/// fails under it, or one the server leaves unanswered for `stale_after`,
211/// which replaces the connection. A change that arrives while a connect or a
212/// replay is in flight waits for that attempt. Changes are sent no faster than
213/// one per 500 ms.
214///
215/// A call fails when the server refuses it ([`SpotWsError::Refused`]), when it
216/// would pass 1024 streams ([`SpotWsError::TooManyStreams`], nothing sent),
217/// when the first connect of a connection it opens fails in a way retrying
218/// cannot fix ([`SpotWsError::Connect`]), or when the client has stopped
219/// ([`SpotWsError::Stopped`]). Its `cause` is the error the client stopped on
220/// once the router has that stop; a call in flight when it stops can get
221/// `Stopped { cause: None }`. A request carries up to 1024 names, so a
222/// subscription is never split.
223///
224/// A call waits for the connection's task, which takes no change while the
225/// consumer leaves 1024 events unread. Make membership changes from a task
226/// other than the one draining the stream, or keep draining while they run.
227#[derive(Debug, Clone)]
228pub struct MembershipHandle {
229    handle: Handle<SpotStreams>,
230}
231
232impl MembershipHandle {
233    /// Subscribe to more streams.
234    pub async fn subscribe(
235        &self,
236        streams: impl IntoIterator<Item = StreamName>,
237    ) -> Result<(), SpotWsError> {
238        self.handle.subscribe(streams.into_iter().collect()).await
239    }
240
241    /// Unsubscribe from streams. A stream not subscribed is ignored.
242    pub async fn unsubscribe(
243        &self,
244        streams: impl IntoIterator<Item = StreamName>,
245    ) -> Result<(), SpotWsError> {
246        self.handle.unsubscribe(streams.into_iter().collect()).await
247    }
248}
249
250/// A supervised connection: a `Stream` of [`Event`]s that survives drops,
251/// stalls and Binance's 24-hour cutoff.
252///
253/// Every [`Event::Disconnected`] is followed by an [`Event::Reconnected`] for
254/// the same path while the client runs. A connection that closes because its
255/// last stream left, while it is up, yields neither.
256///
257/// A fatal error, such as the server refusing a replay after a reconnect, is
258/// yielded as `Err` and then the stream ends. An `Err(SpotWsError::Frame { .. })`,
259/// a frame that did not decode, does not end it.
260///
261/// ```no_run
262/// use futures_util::StreamExt;
263/// use polyoxide_binance::spot::{types::Symbol, ws::{Event, SpotWsBuilder, SpotWsError, StreamName}};
264///
265/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
266/// let btc = Symbol::new("BTCUSDT")?;
267/// let mut feed = SpotWsBuilder::new()
268///     .streams([StreamName::MiniTicker(btc.clone()), StreamName::BookTicker(btc)])
269///     .connect()
270///     .await?;
271/// let membership = feed.membership();
272/// while let Some(event) = feed.next().await {
273///     let event = match event {
274///         Ok(event) => event,
275///         // A frame that did not decode is skipped; anything else ends the stream.
276///         Err(SpotWsError::Frame { .. }) => continue,
277///         Err(err) => return Err(err.into()),
278///     };
279///     match event {
280///         Event::Update(update) => println!("{}", serde_json::to_string(&update)?),
281///         Event::Disconnected { path, reason } => eprintln!("{path} down: {reason}"),
282///         Event::Reconnected { path } => eprintln!("{path} back"),
283///         _ => {}
284///     }
285/// }
286/// # membership.subscribe([StreamName::AllMiniTickers]).await?;
287/// # Ok(())
288/// # }
289/// ```
290pub struct SupervisedSpotWs {
291    running: Running<SpotStreams>,
292}
293
294impl fmt::Debug for SupervisedSpotWs {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.debug_struct("SupervisedSpotWs")
297            .field("running", &self.running.is_running())
298            .finish_non_exhaustive()
299    }
300}
301
302impl SupervisedSpotWs {
303    /// A handle for changing streams while the stream runs.
304    pub fn membership(&self) -> MembershipHandle {
305        MembershipHandle {
306            handle: self.running.membership(),
307        }
308    }
309
310    /// Close the connection and stop. Every [`MembershipHandle`] answers
311    /// [`SpotWsError::Stopped`] afterwards.
312    ///
313    /// Fails with [`SpotWsError::Stopped`] if the client had stopped on an
314    /// error, which the stream yielded: its `cause` is that error, as
315    /// [`Classify`](polyoxide_venue::Classify) read it, and so is the cause
316    /// every later membership call carries. That holds once the router has the
317    /// stop: a `close()` made right after the stream yielded the stopping
318    /// error can return `Ok` or `Stopped { cause: None }`. Read the stream to
319    /// its end first to be sure of the cause.
320    pub async fn close(self) -> Result<(), SpotWsError> {
321        self.running.close().await
322    }
323}
324
325impl Stream for SupervisedSpotWs {
326    type Item = Result<Event, SpotWsError>;
327
328    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
329        self.running.poll_event(cx)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use std::time::Duration;
336
337    use super::*;
338
339    #[test]
340    fn the_defaults_match_the_measured_cadences() {
341        // Spot's server pings every 20 s (OBSERVED.md, `### Pings`); the rest
342        // are usdm's.
343        let b = SpotWsBuilder::new();
344        assert_eq!(b.base_url, "wss://stream.binance.com:9443");
345        assert_eq!(b.timing.ping_interval, Some(Duration::from_secs(20)));
346        assert_eq!(b.timing.stale_after, Duration::from_secs(30));
347        assert_eq!(b.timing.initial_backoff, Duration::from_millis(500));
348        assert_eq!(b.timing.max_backoff, Duration::from_secs(60));
349        assert_eq!(b.timing.connect_timeout, Duration::from_secs(10));
350        assert_eq!(
351            b.timing.max_connection_age,
352            Some(Duration::from_secs(23 * 3600 + 50 * 60))
353        );
354        assert!(b.timing.max_connection_age < Some(Duration::from_secs(24 * 3600)));
355        assert!(b.streams.is_empty());
356    }
357
358    #[test]
359    fn the_setters_replace_their_field_and_default_is_new() {
360        let b = SpotWsBuilder::default()
361            .base_url("ws://127.0.0.1:9")
362            .streams([StreamName::AllMiniTickers]);
363        assert_eq!(b.base_url, "ws://127.0.0.1:9");
364        assert_eq!(b.streams, [StreamName::AllMiniTickers]);
365        assert_eq!(
366            SpotWsBuilder::default().timing.stale_after,
367            SpotWsBuilder::new().timing.stale_after
368        );
369    }
370
371    #[test]
372    fn a_disconnect_reason_reads_as_the_texts_usdm_gives() {
373        assert_eq!(
374            DisconnectReason::Closed {
375                code: Some(1008),
376                reason: "Too many requests".to_owned()
377            }
378            .to_string(),
379            "closed by the server (1008 Too many requests)"
380        );
381        assert_eq!(
382            DisconnectReason::Closed {
383                code: None,
384                reason: String::new()
385            }
386            .to_string(),
387            "closed by the server"
388        );
389        let refused = SpotWsError::Refused {
390            code: 2,
391            msg: "Invalid request".to_owned(),
392        };
393        let text = refused.to_string();
394        assert_eq!(DisconnectReason::Error(refused).to_string(), text);
395        assert_eq!(
396            DisconnectReason::Stale.to_string(),
397            "no frame within the staleness window"
398        );
399        assert_eq!(
400            DisconnectReason::Rotation.to_string(),
401            "rotated before the 24-hour cutoff"
402        );
403    }
404
405    /// A runtime that counts its spawns and runs them on tokio's.
406    #[derive(Debug, Default)]
407    struct CountingRuntime {
408        inner: polyoxide_rt::TokioRuntime,
409        spawns: std::sync::atomic::AtomicUsize,
410    }
411
412    impl Runtime for CountingRuntime {
413        fn spawn(&self, task: polyoxide_rt::BoxFuture<()>) {
414            self.spawns
415                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
416            self.inner.spawn(task);
417        }
418
419        fn sleep(&self, duration: Duration) -> polyoxide_rt::BoxFuture<()> {
420            self.inner.sleep(duration)
421        }
422
423        fn yield_now(&self) -> polyoxide_rt::BoxFuture<()> {
424            Box::pin(tokio::task::yield_now())
425        }
426
427        fn now(&self) -> polyoxide_rt::Instant {
428            self.inner.now()
429        }
430    }
431
432    #[tokio::test]
433    async fn the_router_and_each_path_are_spawned_on_the_injected_runtime() {
434        use crate::spot::ws::scripted::{Script, ScriptedServer};
435
436        let server = ScriptedServer::start(vec![Script::default()]).await;
437        let runtime = std::sync::Arc::new(CountingRuntime::default());
438        let btc = crate::spot::types::Symbol::new("BTCUSDT").unwrap();
439        let feed = SpotWsBuilder::new()
440            .base_url(server.url.clone())
441            .runtime(runtime.clone())
442            .streams([StreamName::AggTrade(btc)])
443            .connect()
444            .await
445            .expect("connect");
446        // One spawn for the path's supervision task, one for its forwarder and
447        // one for the router.
448        assert_eq!(runtime.spawns.load(std::sync::atomic::Ordering::SeqCst), 3);
449        feed.close().await.expect("close");
450    }
451}