Skip to main content

oanda_rs/streaming/
managed.rs

1//! The self-managing stream engine: connection state machine with
2//! heartbeat watchdog, capped exponential backoff, and back-fill support.
3
4use std::collections::VecDeque;
5use std::pin::Pin;
6use std::task::{Context, Poll};
7use std::time::Duration;
8
9use bytes::Bytes;
10use futures_core::Stream;
11use futures_core::future::BoxFuture;
12use futures_core::stream::BoxStream;
13use serde::de::DeserializeOwned;
14use tokio::time::{Instant, Sleep};
15
16use super::StreamConfig;
17use super::json_lines::JsonLines;
18use crate::error::Error;
19
20pub(crate) type ByteStream = BoxStream<'static, reqwest::Result<Bytes>>;
21type Lines<T> = JsonLines<ByteStream, T>;
22type ConnectFuture = BoxFuture<'static, Result<ByteStream, Error>>;
23type BackfillFuture<T> = BoxFuture<'static, Result<Vec<T>, Error>>;
24/// `Some(event)` ends the current poll iteration; `None` continues the state machine.
25type Terminal<T> = Option<Poll<Option<Result<T, Error>>>>;
26
27/// Endpoint-specific behaviour plugged into [`ManagedStream`].
28pub(crate) trait StreamKind: Send + Unpin + 'static {
29    type Item: DeserializeOwned + Send + Unpin + 'static;
30
31    /// Builds a future that opens the connection (waiting for a
32    /// connection-limiter slot, sending the request, and checking the
33    /// response status).
34    fn connect(&mut self, reconnect: bool) -> ConnectFuture;
35
36    /// Called for every item before it is yielded; returning `false` drops
37    /// the item (used to deduplicate back-filled transactions).
38    fn filter(&mut self, _item: &Self::Item) -> bool {
39        true
40    }
41
42    /// Builds an optional back-fill future run after a successful
43    /// reconnect, yielding items missed while disconnected.
44    fn backfill(&mut self) -> Option<BackfillFuture<Self::Item>> {
45        None
46    }
47}
48
49/// A snapshot of a managed stream's connection statistics.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[non_exhaustive]
52pub struct StreamStats {
53    /// Number of successful reconnects performed since the stream was
54    /// created (the initial connection is not counted).
55    pub reconnects: u64,
56    /// Number of failed connection attempts since the stream was created.
57    pub failed_attempts: u64,
58}
59
60enum State<T> {
61    Connecting(ConnectFuture),
62    Backfilling {
63        lines: Lines<T>,
64        future: BackfillFuture<T>,
65    },
66    Draining {
67        lines: Lines<T>,
68        pending: VecDeque<T>,
69    },
70    Streaming {
71        lines: Lines<T>,
72        watchdog: Pin<Box<Sleep>>,
73    },
74    Sleeping(Pin<Box<Sleep>>),
75    Done,
76}
77
78pub(crate) struct ManagedStream<K: StreamKind> {
79    kind: K,
80    config: StreamConfig,
81    state: State<K::Item>,
82    stats: StreamStats,
83    attempts_since_success: u32,
84    current_delay: Duration,
85    connected_at: Option<Instant>,
86}
87
88impl<K: StreamKind> ManagedStream<K> {
89    /// Wraps an already-established connection (the initial connect is
90    /// performed by the endpoint builder so connection errors surface at
91    /// `send()`).
92    pub(crate) fn new(kind: K, config: StreamConfig, initial: ByteStream) -> Self {
93        let heartbeat_timeout = config.heartbeat_timeout;
94        ManagedStream {
95            kind,
96            current_delay: config.backoff_initial,
97            config,
98            state: State::Streaming {
99                lines: JsonLines::new(initial),
100                watchdog: Box::pin(tokio::time::sleep(heartbeat_timeout)),
101            },
102            stats: StreamStats::default(),
103            attempts_since_success: 0,
104            connected_at: Some(Instant::now()),
105        }
106    }
107
108    pub(crate) fn stats(&self) -> StreamStats {
109        self.stats
110    }
111
112    /// Handles a broken connection: either schedules a reconnect (returning
113    /// `None`) or produces the caller-visible terminal event.
114    fn connection_lost(&mut self, error: Option<Error>) -> Terminal<K::Item> {
115        #[cfg(feature = "tracing")]
116        tracing::debug!(error = ?error, "stream connection lost");
117
118        if !self.config.auto_reconnect {
119            self.state = State::Done;
120            return Some(Poll::Ready(error.map(Err)));
121        }
122        // A connection that stayed healthy long enough resets the backoff;
123        // one that died right after connecting keeps escalating it.
124        if let Some(connected_at) = self.connected_at.take() {
125            if connected_at.elapsed() >= self.config.backoff_reset_after {
126                self.attempts_since_success = 0;
127                self.current_delay = self.config.backoff_initial;
128            }
129        }
130        self.schedule_reconnect(error)
131    }
132
133    /// Handles a failed reconnect attempt.
134    fn connect_failed(&mut self, error: Error) -> Terminal<K::Item> {
135        self.stats.failed_attempts += 1;
136
137        #[cfg(feature = "tracing")]
138        tracing::debug!(error = %error, "stream reconnect attempt failed");
139
140        if is_fatal(&error) {
141            self.state = State::Done;
142            return Some(Poll::Ready(Some(Err(error))));
143        }
144        self.schedule_reconnect(Some(error))
145    }
146
147    fn schedule_reconnect(&mut self, error: Option<Error>) -> Terminal<K::Item> {
148        if let Some(max) = self.config.max_reconnect_attempts {
149            if self.attempts_since_success >= max {
150                self.state = State::Done;
151                return Some(Poll::Ready(Some(Err(error.unwrap_or_else(|| {
152                    Error::Stream("reconnect attempts exhausted".into())
153                })))));
154            }
155        }
156        self.attempts_since_success += 1;
157        let delay = jitter(self.current_delay);
158        self.current_delay = (self.current_delay * 2).min(self.config.backoff_max);
159
160        #[cfg(feature = "tracing")]
161        tracing::debug!(delay = ?delay, attempt = self.attempts_since_success, "stream reconnect scheduled");
162
163        self.state = State::Sleeping(Box::pin(tokio::time::sleep(delay)));
164        None
165    }
166}
167
168/// Only client-side errors are fatal; transport failures and server errors
169/// are worth retrying.
170fn is_fatal(error: &Error) -> bool {
171    match error {
172        Error::Api { status, .. } => status.is_client_error(),
173        Error::Config(_) => true,
174        _ => false,
175    }
176}
177
178/// Applies ±25% pseudo-random jitter so reconnecting clients don't
179/// synchronize.
180fn jitter(delay: Duration) -> Duration {
181    let nanos = Instant::now().elapsed().subsec_nanos() as u64 ^ delay.as_nanos() as u64;
182    let factor = 0.75 + (nanos % 1000) as f64 / 2000.0; // 0.75..=1.25
183    delay.mul_f64(factor)
184}
185
186impl<K: StreamKind> Stream for ManagedStream<K> {
187    type Item = Result<K::Item, Error>;
188
189    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
190        let this = self.get_mut();
191        loop {
192            match &mut this.state {
193                State::Connecting(future) => match future.as_mut().poll(cx) {
194                    Poll::Ready(Ok(bytes)) => {
195                        this.stats.reconnects += 1;
196                        this.connected_at = Some(Instant::now());
197                        let lines = JsonLines::new(bytes);
198
199                        #[cfg(feature = "tracing")]
200                        tracing::debug!(reconnects = this.stats.reconnects, "stream reconnected");
201
202                        this.state = match this.kind.backfill() {
203                            Some(future) => State::Backfilling { lines, future },
204                            None => State::Streaming {
205                                lines,
206                                watchdog: Box::pin(tokio::time::sleep(
207                                    this.config.heartbeat_timeout,
208                                )),
209                            },
210                        };
211                    }
212                    Poll::Ready(Err(e)) => {
213                        if let Some(result) = this.connect_failed(e) {
214                            return result;
215                        }
216                    }
217                    Poll::Pending => return Poll::Pending,
218                },
219                State::Backfilling { future, .. } => match future.as_mut().poll(cx) {
220                    Poll::Ready(result) => {
221                        let State::Backfilling { lines, .. } =
222                            std::mem::replace(&mut this.state, State::Done)
223                        else {
224                            unreachable!()
225                        };
226                        match result {
227                            Ok(items) => {
228                                this.state = State::Draining {
229                                    lines,
230                                    pending: items.into(),
231                                };
232                            }
233                            Err(e) => {
234                                // Surface the failed back-fill (there may be
235                                // a gap), but keep the live stream running.
236                                this.state = State::Streaming {
237                                    lines,
238                                    watchdog: Box::pin(tokio::time::sleep(
239                                        this.config.heartbeat_timeout,
240                                    )),
241                                };
242                                return Poll::Ready(Some(Err(e)));
243                            }
244                        }
245                    }
246                    Poll::Pending => return Poll::Pending,
247                },
248                State::Draining { pending, .. } => match pending.pop_front() {
249                    Some(item) => {
250                        if this.kind.filter(&item) {
251                            return Poll::Ready(Some(Ok(item)));
252                        }
253                    }
254                    None => {
255                        let State::Draining { lines, .. } =
256                            std::mem::replace(&mut this.state, State::Done)
257                        else {
258                            unreachable!()
259                        };
260                        this.state = State::Streaming {
261                            lines,
262                            watchdog: Box::pin(tokio::time::sleep(this.config.heartbeat_timeout)),
263                        };
264                    }
265                },
266                State::Streaming { lines, watchdog } => {
267                    match Pin::new(lines).poll_next(cx) {
268                        Poll::Ready(Some(Ok(item))) => {
269                            watchdog
270                                .as_mut()
271                                .reset(Instant::now() + this.config.heartbeat_timeout);
272                            if this.kind.filter(&item) {
273                                return Poll::Ready(Some(Ok(item)));
274                            }
275                        }
276                        Poll::Ready(Some(Err(e @ Error::Decode { .. }))) => {
277                            // A malformed line doesn't invalidate the
278                            // connection; report it and keep streaming.
279                            return Poll::Ready(Some(Err(e)));
280                        }
281                        Poll::Ready(Some(Err(e))) => {
282                            if let Some(result) = this.connection_lost(Some(e)) {
283                                return result;
284                            }
285                        }
286                        Poll::Ready(None) => {
287                            if let Some(result) = this.connection_lost(None) {
288                                return result;
289                            }
290                        }
291                        Poll::Pending => match watchdog.as_mut().poll(cx) {
292                            Poll::Ready(()) => {
293                                let stale = Error::Stream(format!(
294                                    "no data within {:?} (heartbeats expected every 5s); connection considered stale",
295                                    this.config.heartbeat_timeout
296                                ));
297                                if let Some(result) = this.connection_lost(Some(stale)) {
298                                    return result;
299                                }
300                            }
301                            Poll::Pending => return Poll::Pending,
302                        },
303                    }
304                }
305                State::Sleeping(sleep) => match sleep.as_mut().poll(cx) {
306                    Poll::Ready(()) => {
307                        this.state = State::Connecting(this.kind.connect(true));
308                    }
309                    Poll::Pending => return Poll::Pending,
310                },
311                State::Done => return Poll::Ready(None),
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use futures_util::StreamExt;
321    use std::sync::{Arc, Mutex};
322
323    /// Scripted connection outcomes for driving the state machine.
324    enum Outcome {
325        /// Connection refused (non-fatal).
326        Fail,
327        /// Connection refused with a fatal (4xx) error.
328        FailFatal,
329        /// Connects; yields the chunks, then EOF.
330        Chunks(Vec<&'static [u8]>),
331        /// Connects; yields the chunks, then hangs forever.
332        ChunksThenHang(Vec<&'static [u8]>),
333        /// Connects; yields one heartbeat line every `gap` for `count`
334        /// lines, then EOF.
335        Spaced { count: u32, gap: Duration },
336    }
337
338    struct MockKind {
339        script: std::collections::VecDeque<Outcome>,
340        connects: Arc<Mutex<Vec<Instant>>>,
341    }
342
343    impl MockKind {
344        fn new(script: Vec<Outcome>) -> (Self, Arc<Mutex<Vec<Instant>>>) {
345            let connects = Arc::new(Mutex::new(Vec::new()));
346            (
347                MockKind {
348                    script: script.into(),
349                    connects: Arc::clone(&connects),
350                },
351                connects,
352            )
353        }
354    }
355
356    fn bytes_from(outcome: Outcome) -> Result<ByteStream, Error> {
357        use futures_util::stream;
358        match outcome {
359            Outcome::Fail => Err(Error::Stream("connection refused".into())),
360            Outcome::FailFatal => Err(Error::Api {
361                status: reqwest::StatusCode::UNAUTHORIZED,
362                request_id: None,
363                body: crate::error::ApiErrorBody::from_text("nope".into()),
364            }),
365            Outcome::Chunks(chunks) => {
366                Ok(stream::iter(chunks.into_iter().map(|c| Ok(Bytes::from_static(c)))).boxed())
367            }
368            Outcome::ChunksThenHang(chunks) => Ok(stream::iter(
369                chunks.into_iter().map(|c| Ok(Bytes::from_static(c))),
370            )
371            .chain(stream::pending())
372            .boxed()),
373            Outcome::Spaced { count, gap } => Ok(stream::unfold(0u32, move |i| async move {
374                if i >= count {
375                    return None;
376                }
377                tokio::time::sleep(gap).await;
378                Some((Ok(Bytes::from_static(b"{\"type\":\"HEARTBEAT\"}\n")), i + 1))
379            })
380            .boxed()),
381        }
382    }
383
384    impl StreamKind for MockKind {
385        type Item = serde_json::Value;
386
387        fn connect(&mut self, _reconnect: bool) -> ConnectFuture {
388            self.connects.lock().unwrap().push(Instant::now());
389            let outcome = self.script.pop_front().expect("script exhausted");
390            Box::pin(async move { bytes_from(outcome) })
391        }
392    }
393
394    fn config() -> StreamConfig {
395        StreamConfig::default()
396    }
397
398    fn managed(
399        script: Vec<Outcome>,
400        initial: Outcome,
401        config: StreamConfig,
402    ) -> (ManagedStream<MockKind>, Arc<Mutex<Vec<Instant>>>) {
403        let (kind, connects) = MockKind::new(script);
404        let initial = bytes_from(initial).unwrap();
405        (ManagedStream::new(kind, config, initial), connects)
406    }
407
408    #[tokio::test(start_paused = true)]
409    async fn backoff_escalates_with_cap_during_outage() {
410        // Initial connection dies immediately; every reconnect fails. With
411        // max 10 attempts the stream must end with an error, and the gaps
412        // between attempts must escalate 1s→2s→…→300s cap (±25% jitter).
413        let mut cfg = config();
414        cfg.max_reconnect_attempts = Some(10);
415        let script = (0..10).map(|_| Outcome::Fail).collect();
416        let (stream, connects) = managed(script, Outcome::Chunks(vec![]), cfg);
417        let start = Instant::now();
418        let items: Vec<_> = stream.collect().await;
419        assert_eq!(items.len(), 1);
420        assert!(items[0].is_err(), "expected terminal error");
421
422        let connects = connects.lock().unwrap();
423        assert_eq!(connects.len(), 10);
424        let mut previous = start;
425        for (i, at) in connects.iter().enumerate() {
426            let expected = Duration::from_secs(1 << i).min(Duration::from_secs(300));
427            let delta = at.duration_since(previous);
428            assert!(
429                delta >= expected.mul_f64(0.74) && delta <= expected.mul_f64(1.26),
430                "attempt {i}: delta {delta:?}, expected ~{expected:?}"
431            );
432            previous = *at;
433        }
434        // A multi-hour outage keeps the cadence at the 5-minute cap: the
435        // last gap must be ~300s, not still growing.
436        let last = connects[9].duration_since(connects[8]);
437        assert!(last >= Duration::from_secs(225) && last <= Duration::from_secs(375));
438    }
439
440    #[tokio::test(start_paused = true)]
441    async fn stable_connection_resets_backoff() {
442        // fail, fail, then a connection healthy for >60s, then fail once
443        // more: the delay after the healthy connection must be back at
444        // ~1s, not continuing to escalate.
445        let script = vec![
446            Outcome::Fail,
447            Outcome::Fail,
448            Outcome::Spaced {
449                count: 13,
450                gap: Duration::from_secs(5),
451            }, // healthy ~65s
452            Outcome::Fail,
453            Outcome::Chunks(vec![b"{\"ok\":1}\n"]),
454        ];
455        let mut cfg = config();
456        cfg.max_reconnect_attempts = Some(100);
457        let (stream, connects) = managed(script, Outcome::Chunks(vec![]), cfg);
458        // 13 heartbeats + 1 final item; stream keeps reconnecting after
459        // the last EOF, so just take what we expect.
460        let items: Vec<_> = stream.take(14).collect().await;
461        assert_eq!(items.iter().filter(|r| r.is_ok()).count(), 14);
462
463        let connects = connects.lock().unwrap();
464        // connect #2 (index 2, healthy) ends ~65s after it starts; connect
465        // #3 (index 3) happens j(1s) later because the backoff reset.
466        let healthy_end = connects[2] + Duration::from_secs(65);
467        let delta = connects[3].duration_since(healthy_end);
468        assert!(
469            delta <= Duration::from_millis(1300),
470            "backoff was not reset after stable connection: {delta:?}"
471        );
472    }
473
474    #[tokio::test(start_paused = true)]
475    async fn short_lived_connection_keeps_escalating() {
476        // fail, then a connection that dies instantly, then fail: the
477        // delay after the instant death must continue the escalation
478        // (~4s), not reset to 1s.
479        let script = vec![
480            Outcome::Fail,
481            Outcome::Chunks(vec![]), // connects, dies immediately
482            Outcome::Fail,
483            Outcome::Chunks(vec![b"{\"ok\":1}\n"]),
484        ];
485        let (stream, connects) = managed(script, Outcome::Chunks(vec![]), config());
486        let items: Vec<_> = stream.take(1).collect().await;
487        assert!(items[0].is_ok());
488
489        let connects = connects.lock().unwrap();
490        // delays: j(1) before #0, j(2) before #1, j(4) before #2? No —
491        // successful connect #1 dies instantly (unstable), so the delay
492        // before #2 continues at j(4).
493        let delta = connects[2].duration_since(connects[1]);
494        assert!(
495            delta >= Duration::from_millis(2900),
496            "backoff reset after an unstable connection: {delta:?}"
497        );
498    }
499
500    #[tokio::test(start_paused = true)]
501    async fn watchdog_detects_stale_connection() {
502        // The initial connection sends one line then hangs. The watchdog
503        // (10s) must declare it stale and reconnect.
504        let script = vec![Outcome::Chunks(vec![b"{\"n\":2}\n"])];
505        let (stream, connects) = managed(
506            script,
507            Outcome::ChunksThenHang(vec![b"{\"n\":1}\n"]),
508            config(),
509        );
510        let start = Instant::now();
511        let items: Vec<_> = stream.take(2).collect().await;
512        assert_eq!(items.iter().filter(|r| r.is_ok()).count(), 2);
513
514        let connects = connects.lock().unwrap();
515        let delta = connects[0].duration_since(start);
516        // ~10s watchdog + ~1s backoff (with jitter).
517        assert!(
518            delta >= Duration::from_millis(10_700) && delta <= Duration::from_millis(11_300),
519            "unexpected stale detection timing: {delta:?}"
520        );
521    }
522
523    #[tokio::test(start_paused = true)]
524    async fn fatal_error_ends_stream() {
525        let script = vec![Outcome::FailFatal];
526        let (stream, _) = managed(script, Outcome::Chunks(vec![]), config());
527        let items: Vec<_> = stream.collect().await;
528        assert_eq!(items.len(), 1);
529        match &items[0] {
530            Err(Error::Api { status, .. }) => assert_eq!(status.as_u16(), 401),
531            other => panic!("expected fatal Api error, got {other:?}"),
532        }
533    }
534
535    #[tokio::test(start_paused = true)]
536    async fn auto_reconnect_disabled_ends_on_eof() {
537        let mut cfg = config();
538        cfg.auto_reconnect = false;
539        let (stream, connects) = managed(vec![], Outcome::Chunks(vec![b"{\"n\":1}\n"]), cfg);
540        let items: Vec<_> = stream.collect().await;
541        assert_eq!(items.len(), 1);
542        assert!(items[0].is_ok());
543        assert!(connects.lock().unwrap().is_empty(), "must not reconnect");
544    }
545
546    #[tokio::test(start_paused = true)]
547    async fn stats_count_reconnects() {
548        let script = vec![Outcome::Fail, Outcome::Chunks(vec![b"{\"n\":1}\n"])];
549        let mut cfg = config();
550        cfg.auto_reconnect = true;
551        let (mut stream, _) = managed(script, Outcome::Chunks(vec![]), cfg);
552        let first = stream.next().await.unwrap();
553        assert!(first.is_ok());
554        let stats = stream.stats();
555        assert_eq!(stats.reconnects, 1);
556        assert_eq!(stats.failed_attempts, 1);
557    }
558}