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}