Skip to main content

yellowstone_grpc_client/
reconnect.rs

1use {
2    crate::{
3        dedup::{DedupState, DedupStream},
4        GeyserGrpcClientError, InterceptorXToken, ReconnectConfig,
5    },
6    arc_swap::ArcSwap,
7    futures::{channel::mpsc, sink::SinkExt, stream::Stream},
8    std::{
9        error::Error,
10        future::Future,
11        pin::Pin,
12        sync::{Arc, Mutex},
13        task::{Context, Poll},
14        time::Duration,
15    },
16    tonic::{
17        codec::CompressionEncoding, metadata::AsciiMetadataValue, transport::Endpoint, Code,
18        Status, Streaming,
19    },
20    yellowstone_grpc_proto::{
21        geyser::geyser_client::GeyserClient,
22        prelude::{subscribe_update::UpdateOneof, SubscribeRequest, SubscribeUpdate},
23    },
24};
25
26/// Number of slots behind the last block_meta to checkpoint.
27/// Conservative buffer to account for late-arriving events
28/// and out-of-order delivery within a slot.
29const CHECKPOINT_SLOT_BUFFER: u64 = 2;
30
31pub const AUTORECONNECT_FILTER_KEY: &str = "__autoreconnect";
32
33type ConnectFuture<S> =
34    Pin<Box<dyn Future<Output = Result<DedupStream<S>, GeyserGrpcClientError>> + Send + 'static>>;
35
36#[derive(Debug, Clone)]
37pub struct Backoff {
38    pub initial_interval: Duration,
39    pub multiplier: f64,
40    pub max_retries: u32,
41}
42
43pub(crate) enum ErrorCategory<E> {
44    Retryable(E),
45    Unrecoverable(E),
46}
47
48impl<E> ErrorCategory<E> {
49    const fn is_retryable(&self) -> bool {
50        matches!(self, Self::Retryable(_))
51    }
52
53    fn into_inner(self) -> E {
54        match self {
55            Self::Retryable(e) | Self::Unrecoverable(e) => e,
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61/// Mutable runtime state for a single backoff execution.
62struct BackoffInstance {
63    current_interval: Duration,
64    attempts: u32,
65    multiplier: f64,
66    max_retries: u32,
67}
68
69impl BackoffInstance {
70    const fn new(config: &Backoff) -> Self {
71        Self {
72            current_interval: config.initial_interval,
73            attempts: 0,
74            multiplier: config.multiplier,
75            max_retries: config.max_retries,
76        }
77    }
78
79    /// Returns true once the configured retry budget has been consumed.
80    const fn exhausted(&self) -> bool {
81        self.attempts >= self.max_retries
82    }
83
84    /// Advances retry counters and computes the next interval.
85    fn advance(&mut self) {
86        self.attempts += 1;
87        self.current_interval = self.current_interval.mul_f64(self.multiplier);
88    }
89}
90
91impl Iterator for BackoffInstance {
92    type Item = Duration;
93
94    fn next(&mut self) -> Option<Self::Item> {
95        if self.exhausted() {
96            None
97        } else {
98            let interval = self.current_interval;
99            self.advance();
100            Some(interval)
101        }
102    }
103}
104
105impl Backoff {
106    const fn default_initial_interval() -> Duration {
107        Duration::from_millis(10)
108    }
109
110    const fn default_multiplier() -> f64 {
111        2.0
112    }
113
114    const fn default_max_retries() -> u32 {
115        3
116    }
117
118    /// Creates a new backoff policy.
119    pub const fn new(initial_interval: Duration, multiplier: f64, max_retries: u32) -> Self {
120        Self {
121            initial_interval,
122            multiplier,
123            max_retries,
124        }
125    }
126
127    const fn instance(&self) -> BackoffInstance {
128        BackoffInstance::new(self)
129    }
130
131    /// Retries an async operation with exponential backoff
132    ///
133    /// The operation returns `ErrorCategory::Retryable` for transient failures,
134    /// and `ErrorCategory::Unrecoverable` for terminal failures.
135    pub(crate) fn retry<F, Fut, T, E>(&self, mut operation: F) -> impl Future<Output = Result<T, E>>
136    where
137        F: FnMut() -> Fut,
138        Fut: Future<Output = Result<T, ErrorCategory<E>>>,
139    {
140        let mut state = self.instance();
141        async move {
142            loop {
143                match operation().await {
144                    Ok(value) => return Ok(value),
145                    Err(error) => {
146                        if !error.is_retryable() {
147                            return Err(error.into_inner());
148                        }
149
150                        let Some(sleep_for) = state.next() else {
151                            return Err(error.into_inner());
152                        };
153
154                        tokio::time::sleep(sleep_for).await;
155                    }
156                }
157            }
158        }
159    }
160}
161
162impl Default for Backoff {
163    fn default() -> Self {
164        Self::new(
165            Self::default_initial_interval(),
166            Self::default_multiplier(),
167            Self::default_max_retries(),
168        )
169    }
170}
171
172/// Connector trait used by AutoReconnect to create new subscribe streams.
173pub trait GrpcConnector: Clone + Send + Sync + 'static {
174    type Stream: Stream<Item = Result<SubscribeUpdate, Status>> + Unpin + Send + 'static;
175
176    type ConnectError: std::error::Error + Send + Sync + 'static;
177
178    type ConnectFuture: Future<Output = Result<Self::Stream, Self::ConnectError>> + Send + 'static;
179
180    /// `connect()` takes the latest subscribe request and an optional checkpoint slot
181    /// for replay. returns a future that resolves to a fresh stream or an error.
182    fn connect(
183        &self,
184        request: Arc<SubscribeRequest>,
185        from_slot: Option<u64>,
186    ) -> Self::ConnectFuture;
187}
188
189/// Tonic connector implementation for AutoReconnect. on reconnect, creates a new channel,
190/// subscribes, and swaps the sender in the user's SubscribeRequestSink
191/// so both sides of the bidi stream point to the new connection.
192#[derive(Clone)]
193pub struct TonicGrpcConnector {
194    backoff: Backoff,
195    request_sink: Arc<Mutex<mpsc::Sender<SubscribeRequest>>>,
196    endpoint: Endpoint,
197    x_token: Option<AsciiMetadataValue>,
198    x_request_snapshot: bool,
199    send_compressed: Option<CompressionEncoding>,
200    accept_compressed: Option<CompressionEncoding>,
201    max_decoding_message_size: Option<usize>,
202    max_encoding_message_size: Option<usize>,
203}
204
205#[derive(Debug, Clone)]
206pub struct TonicGeyserClientOptions {
207    pub x_request_snapshot: bool,
208    pub send_compressed: Option<CompressionEncoding>,
209    pub accept_compressed: Option<CompressionEncoding>,
210    pub max_decoding_message_size: Option<usize>,
211    pub max_encoding_message_size: Option<usize>,
212}
213
214impl Default for TonicGeyserClientOptions {
215    fn default() -> Self {
216        Self {
217            x_request_snapshot: false,
218            send_compressed: None,
219            accept_compressed: None,
220            max_decoding_message_size: Some(50_000_000), // 50mb default max message size for tonic
221            max_encoding_message_size: Some(50_000_000),
222        }
223    }
224}
225
226impl TonicGrpcConnector {
227    pub const fn new(
228        endpoint: Endpoint,
229        config: ReconnectConfig,
230        x_token: Option<AsciiMetadataValue>,
231        options: TonicGeyserClientOptions,
232        request_sink: Arc<Mutex<mpsc::Sender<SubscribeRequest>>>,
233    ) -> Self {
234        Self {
235            backoff: config.backoff,
236            request_sink,
237            endpoint,
238            x_token,
239            x_request_snapshot: options.x_request_snapshot,
240            send_compressed: options.send_compressed,
241            accept_compressed: options.accept_compressed,
242            max_decoding_message_size: options.max_decoding_message_size,
243            max_encoding_message_size: options.max_encoding_message_size,
244        }
245    }
246}
247
248impl GrpcConnector for TonicGrpcConnector {
249    type Stream = Streaming<SubscribeUpdate>;
250    type ConnectError = GeyserGrpcClientError;
251    type ConnectFuture =
252        Pin<Box<dyn Future<Output = Result<Self::Stream, Self::ConnectError>> + Send + 'static>>;
253
254    fn connect(
255        &self,
256        request: Arc<SubscribeRequest>,
257        from_slot: Option<u64>,
258    ) -> Self::ConnectFuture {
259        let backoff = self.backoff.clone();
260        let endpoint = self.endpoint.clone();
261        let request_sink = Arc::clone(&self.request_sink);
262        let x_token = self.x_token.clone();
263        let x_request_snapshot = self.x_request_snapshot;
264        let send_compressed = self.send_compressed;
265        let accept_compressed = self.accept_compressed;
266        let max_decoding_message_size = self.max_decoding_message_size;
267        let max_encoding_message_size = self.max_encoding_message_size;
268        let base_request = (*request).clone();
269
270        let fut = backoff.retry(move || {
271            let endpoint = endpoint.clone();
272            let request_sink = Arc::clone(&request_sink);
273            let x_token = x_token.clone();
274            let mut request = base_request.clone();
275            async move {
276                request.from_slot = from_slot;
277
278                let channel = endpoint
279                    .connect()
280                    .await
281                    .map_err(GeyserGrpcClientError::TransportError)
282                    .map_err(ErrorCategory::Retryable)?;
283
284                let interceptor = InterceptorXToken {
285                    x_token,
286                    x_request_snapshot,
287                };
288
289                let mut geyser =
290                    GeyserClient::with_interceptor(channel.clone(), interceptor.clone());
291                if let Some(encoding) = send_compressed {
292                    geyser = geyser.send_compressed(encoding);
293                }
294                if let Some(encoding) = accept_compressed {
295                    geyser = geyser.accept_compressed(encoding);
296                }
297                if let Some(limit) = max_decoding_message_size {
298                    geyser = geyser.max_decoding_message_size(limit);
299                }
300                if let Some(limit) = max_encoding_message_size {
301                    geyser = geyser.max_encoding_message_size(limit);
302                }
303
304                let (mut subscribe_tx, subscribe_rx) = mpsc::channel(1000);
305                subscribe_tx
306                    .send(request)
307                    .await
308                    .expect("channel cannot be disconnected or full at this point");
309
310                let mut tonic_request = tonic::Request::new(subscribe_rx);
311                if let Some(slot) = from_slot {
312                    tonic_request.metadata_mut().insert(
313                        "x-min-context-slot",
314                        slot.to_string().parse().expect("slot is valid metadata"),
315                    );
316                }
317
318                let response = geyser
319                    .subscribe(tonic_request)
320                    .await
321                    .map_err(GeyserGrpcClientError::from)
322                    .map_err(|e| {
323                        if is_recoverable_client_error(&e) {
324                            ErrorCategory::Retryable(e)
325                        } else {
326                            ErrorCategory::Unrecoverable(e)
327                        }
328                    })?;
329
330                // Reconnect creates a new bidi request channel; swap sender so user-facing
331                // SubscribeRequestSink continues writing into the active stream.
332                *request_sink.lock().expect("request sink mutex poisoned") = subscribe_tx;
333
334                Ok(response.into_inner())
335            }
336        });
337
338        Box::pin(fut)
339    }
340}
341
342/// Stream wrapper that transparently reconnects on recoverable failures.
343/// delegates connection logic to a `GrpcConnector` and preserves dedup state across reconnects.
344///
345/// `request` is shared with `SubscribeRequestSink` via `ArcSwap`, when the user
346/// sends a new subscribe request mid-stream, ArcSwap stores it atomically.
347/// on reconnect, we load the latest request so the new connection gets
348/// the user's current filters, not the stale initial ones.
349pub struct AutoReconnect<GrpcStream, Connector> {
350    request: Arc<ArcSwap<SubscribeRequest>>,
351    last_checkpoint: Option<u64>,
352    stop: bool,
353    connector: Connector,
354    backoff: Backoff,
355    inner_stream: Option<DedupStream<GrpcStream>>,
356    pending_connecting_task: Option<ConnectFuture<GrpcStream>>,
357}
358
359impl<S, Connector> AutoReconnect<S, Connector>
360where
361    S: Stream<Item = Result<SubscribeUpdate, Status>> + Unpin + Send + 'static,
362    Connector: GrpcConnector<Stream = S, ConnectError = GeyserGrpcClientError>,
363{
364    pub fn new(
365        stream: DedupStream<S>,
366        connector: Connector,
367        request: Arc<ArcSwap<SubscribeRequest>>,
368        backoff: Backoff,
369    ) -> Self {
370        Self {
371            request,
372            last_checkpoint: None,
373            inner_stream: Some(stream),
374            pending_connecting_task: None,
375            stop: false,
376            connector,
377            backoff,
378        }
379    }
380
381    fn make_connection_future(&self, dedup_state: DedupState) -> ConnectFuture<S> {
382        let connector = self.connector.clone();
383        let request = self.request.load_full();
384        let from_slot = self.last_checkpoint;
385        let fut = async move {
386            let stream = connector.connect(request, from_slot).await?;
387            Ok(DedupStream::new(stream, dedup_state))
388        };
389        Box::pin(fut)
390    }
391}
392
393impl<S, Connector> Stream for AutoReconnect<S, Connector>
394where
395    S: Stream<Item = Result<SubscribeUpdate, Status>> + Unpin + Send + 'static,
396    Connector: GrpcConnector<Stream = S, ConnectError = GeyserGrpcClientError> + Unpin,
397{
398    type Item = Result<SubscribeUpdate, Status>;
399
400    /// State machine with two states:
401    /// - inner_stream is Some -> poll it for messages, checkpoint on BlockMeta
402    /// - pending_connecting_task is Some -> poll the reconnect future
403    ///
404    /// on recoverable error: extract dedup state, start reconnect, loop back.
405    /// on unrecoverable error or stream end: stop permanently.
406    /// the while loop avoids wake_by_ref(), state transitions fall through immediately.
407    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
408        if self.stop {
409            return Poll::Ready(None);
410        }
411        let me = self.get_mut();
412        while !me.stop {
413            if let Some(mut stream) = me.inner_stream.take() {
414                match Pin::new(&mut stream).poll_next(cx) {
415                    Poll::Ready(Some(Ok(mut msg))) => {
416                        if let Some(UpdateOneof::BlockMeta(_)) = msg.update_oneof.as_ref() {
417                            if let Some(slot) = extract_slot(&msg) {
418                                // checkpoint a few slots behind the last fully built slot.
419                                // block_meta can arrive late and events within a slot
420                                // arrive in random order, replaying from a couple slots
421                                // back guarantees we don't miss data. dedup handles
422                                // the duplicates this creates.
423                                me.last_checkpoint =
424                                    Some(slot.saturating_sub(CHECKPOINT_SLOT_BUFFER));
425                            }
426                        }
427
428                        // The internal __autoreconnect filter is injected into slots/blocks_meta/entry
429                        // to guarantee the messages our invariants need (checkpointing, equivocation guard)
430
431                        if !msg.filters.is_empty()
432                            && msg.filters.iter().all(|f| f == AUTORECONNECT_FILTER_KEY)
433                        {
434                            // matched only our internal key (incl. empty) -> user never asked for this
435                            me.inner_stream = Some(stream);
436                            continue;
437                        }
438
439                        // matched a user filter too -> strip the internal key, forward the rest
440                        msg.filters.retain(|f| f != AUTORECONNECT_FILTER_KEY);
441
442                        me.inner_stream = Some(stream);
443                        return Poll::Ready(Some(Ok(msg)));
444                    }
445                    Poll::Ready(Some(Err(status))) if is_recoverable_status_code(status.code()) => {
446                        // If the server's replay buffer has moved past our checkpoint,
447                        // clear it so the next reconnect starts from "now" instead of
448                        // looping forever on an unavailable slot.
449                        if status.code() == Code::OutOfRange {
450                            me.last_checkpoint = None;
451                        }
452
453                        if me.backoff.max_retries == 0 {
454                            me.stop = true;
455                            return Poll::Ready(Some(Err(status)));
456                        }
457
458                        log::warn!(
459                            "stream error: {status}. starting reconnect from slot {:?}",
460                            me.last_checkpoint
461                        );
462
463                        let mut dedup_state = stream.state;
464                        dedup_state.prepare_for_replay();
465                        me.pending_connecting_task = Some(me.make_connection_future(dedup_state));
466                    }
467                    Poll::Ready(Some(Err(e))) => {
468                        me.stop = true;
469                        return Poll::Ready(Some(Err(e)));
470                    }
471                    Poll::Ready(None) => {
472                        me.stop = true;
473                        return Poll::Ready(None);
474                    }
475                    Poll::Pending => {
476                        me.inner_stream = Some(stream);
477                        return Poll::Pending;
478                    }
479                }
480            }
481
482            if let Some(mut fut) = me.pending_connecting_task.take() {
483                match fut.as_mut().poll(cx) {
484                    Poll::Ready(result) => match result {
485                        Ok(stream) => {
486                            me.inner_stream = Some(stream);
487                        }
488                        Err(error) => {
489                            me.stop = true;
490                            return Poll::Ready(Some(Err(Status::internal(format!(
491                                "reconnect failed: {error}",
492                            )))));
493                        }
494                    },
495                    Poll::Pending => {
496                        me.pending_connecting_task = Some(fut);
497                        return Poll::Pending;
498                    }
499                }
500            }
501            assert!(
502                me.inner_stream.is_some() || me.pending_connecting_task.is_some() || me.stop,
503                "must have either an active stream, a pending connecting task, or be stopped"
504            );
505        }
506
507        Poll::Ready(None)
508    }
509}
510
511/// Returns true if a client error is considered transient and reconnectable.
512fn is_recoverable_client_error(err: &GeyserGrpcClientError) -> bool {
513    match err {
514        GeyserGrpcClientError::TonicStatus(status) => {
515            // Transport errors can be wrapped inside a Status
516            if let Some(source) = status.source() {
517                if source.downcast_ref::<tonic::transport::Error>().is_some() {
518                    return true;
519                }
520            }
521            is_recoverable_status_code(status.code())
522        }
523        GeyserGrpcClientError::TransportError(_) => true,
524    }
525}
526
527/// Returns true for gRPC status codes that should trigger reconnect.
528const fn is_recoverable_status_code(code: Code) -> bool {
529    matches!(
530        code,
531        Code::Cancelled
532            | Code::Unknown
533            | Code::DeadlineExceeded
534            | Code::ResourceExhausted
535            | Code::Aborted
536            | Code::Internal
537            | Code::Unavailable
538            | Code::DataLoss
539            | Code::OutOfRange
540    )
541}
542
543/// Extracts the slot number from a subscribe update when available.
544pub(crate) fn extract_slot(msg: &SubscribeUpdate) -> Option<u64> {
545    match msg.update_oneof.as_ref()? {
546        UpdateOneof::Account(m) => Some(m.slot),
547        UpdateOneof::Slot(m) => Some(m.slot),
548        UpdateOneof::Transaction(m) => Some(m.slot),
549        UpdateOneof::Block(m) => Some(m.slot),
550        UpdateOneof::BlockMeta(m) => Some(m.slot),
551        UpdateOneof::Entry(m) => Some(m.slot),
552        UpdateOneof::TransactionStatus(m) => Some(m.slot),
553        UpdateOneof::Ping(_) | UpdateOneof::Pong(_) => None,
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use {
560        super::*,
561        futures::{stream, StreamExt},
562        std::{
563            collections::VecDeque,
564            sync::{Arc, Mutex},
565        },
566        tonic::Code,
567        yellowstone_grpc_proto::{
568            geyser::{
569                SubscribeUpdateAccount, SubscribeUpdateAccountInfo, SubscribeUpdateBlockMeta,
570            },
571            prelude::{subscribe_update::UpdateOneof, SubscribeUpdatePing, SubscribeUpdateSlot},
572        },
573    };
574
575    #[derive(Clone)]
576    struct MockGrpcConnector {
577        plans: Arc<Mutex<VecDeque<ConnectPlan>>>,
578        from_slot_calls: Arc<Mutex<Vec<Option<u64>>>>,
579    }
580
581    struct ConnectPlan {
582        expected_from_slot: Option<Option<u64>>,
583        result: Result<Vec<Result<SubscribeUpdate, Status>>, GeyserGrpcClientError>,
584    }
585
586    impl MockGrpcConnector {
587        fn new(plans: Vec<ConnectPlan>) -> Self {
588            Self {
589                plans: Arc::new(Mutex::new(plans.into())),
590                from_slot_calls: Arc::new(Mutex::new(Vec::new())),
591            }
592        }
593
594        fn calls(&self) -> Vec<Option<u64>> {
595            self.from_slot_calls
596                .lock()
597                .expect("calls mutex poisoned")
598                .clone()
599        }
600    }
601
602    impl GrpcConnector for MockGrpcConnector {
603        type Stream = futures::stream::BoxStream<'static, Result<SubscribeUpdate, Status>>;
604        type ConnectError = GeyserGrpcClientError;
605        type ConnectFuture = Pin<
606            Box<dyn Future<Output = Result<Self::Stream, Self::ConnectError>> + Send + 'static>,
607        >;
608
609        fn connect(
610            &self,
611            _request: Arc<SubscribeRequest>,
612            from_slot: Option<u64>,
613        ) -> Self::ConnectFuture {
614            let mut plans = self.plans.lock().expect("plans mutex poisoned");
615            let plan = plans
616                .pop_front()
617                .expect("unexpected connect call without a test plan");
618            drop(plans);
619
620            self.from_slot_calls
621                .lock()
622                .expect("calls mutex poisoned")
623                .push(from_slot);
624
625            if let Some(expected_from_slot) = plan.expected_from_slot {
626                assert_eq!(
627                    from_slot, expected_from_slot,
628                    "unexpected reconnect from_slot"
629                );
630            }
631
632            Box::pin(async move {
633                match plan.result {
634                    Ok(items) => Ok(stream::iter(items).boxed()),
635                    Err(err) => Err(err),
636                }
637            })
638        }
639    }
640
641    fn backoff_with_retries(max_retries: u32) -> Backoff {
642        Backoff::new(Duration::from_millis(0), 1.0, max_retries)
643    }
644
645    fn request_state(request: SubscribeRequest) -> Arc<ArcSwap<SubscribeRequest>> {
646        Arc::new(ArcSwap::new(Arc::new(request)))
647    }
648
649    #[test]
650    fn test_backoff_default() {
651        let backoff = Backoff::default();
652        assert_eq!(backoff.initial_interval, Duration::from_millis(10));
653        assert_eq!(backoff.multiplier, 2.0);
654        assert_eq!(backoff.max_retries, 3);
655    }
656
657    #[test]
658    fn test_backoff_instance_exhaustion() {
659        let backoff = Backoff::new(Duration::from_millis(100), 2.0, 3);
660        let mut instance = backoff.instance();
661
662        assert!(!instance.exhausted());
663        instance.attempts = 3;
664        assert!(instance.exhausted());
665    }
666
667    #[test]
668    fn test_backoff_instance_starts_from_initial() {
669        let backoff = Backoff::new(Duration::from_millis(100), 2.0, 3);
670        let instance = backoff.instance();
671        assert_eq!(instance.attempts, 0);
672        assert_eq!(instance.current_interval, Duration::from_millis(100));
673    }
674
675    #[test]
676    fn test_backoff_advance() {
677        let backoff = Backoff::new(Duration::from_millis(100), 2.0, 5);
678        let mut instance = backoff.instance();
679        assert_eq!(instance.current_interval, Duration::from_millis(100));
680
681        instance.advance();
682        assert_eq!(instance.attempts, 1);
683        assert_eq!(instance.current_interval, Duration::from_millis(200));
684
685        instance.advance();
686        assert_eq!(instance.attempts, 2);
687        assert_eq!(instance.current_interval, Duration::from_millis(400));
688    }
689
690    #[test]
691    fn test_backoff_advance_unbounded_growth() {
692        let backoff = Backoff::new(Duration::from_millis(500), 2.0, 10);
693        let mut instance = backoff.instance();
694        instance.advance(); // 1s
695        instance.advance(); // 2s
696        assert_eq!(instance.current_interval, Duration::from_secs(2));
697    }
698
699    #[test]
700    fn test_backoff_iterator_yields_expected_intervals() {
701        let backoff = Backoff::new(Duration::from_millis(100), 2.0, 3);
702        let mut instance = backoff.instance();
703
704        assert_eq!(instance.next(), Some(Duration::from_millis(100)));
705        assert_eq!(instance.next(), Some(Duration::from_millis(200)));
706        assert_eq!(instance.next(), Some(Duration::from_millis(400)));
707        assert_eq!(instance.next(), None);
708    }
709
710    #[test]
711    fn test_backoff_iterator_stops_after_max_retries() {
712        let backoff = Backoff::new(Duration::from_millis(100), 2.0, 2);
713        let mut instance = backoff.instance();
714
715        assert_eq!(instance.next(), Some(Duration::from_millis(100)));
716        assert_eq!(instance.next(), Some(Duration::from_millis(200)));
717        assert_eq!(instance.next(), None);
718        assert_eq!(instance.next(), None);
719    }
720
721    #[tokio::test]
722    async fn test_backoff_retry_eventually_succeeds() {
723        let backoff = Backoff::new(Duration::from_millis(0), 2.0, 5);
724        let mut calls = 0;
725
726        let result = backoff
727            .retry(|| {
728                let current = calls;
729                calls += 1;
730                async move {
731                    if current < 2 {
732                        Err(ErrorCategory::Retryable("transient"))
733                    } else {
734                        Ok(42)
735                    }
736                }
737            })
738            .await;
739
740        assert_eq!(result, Ok(42));
741        assert_eq!(calls, 3);
742    }
743
744    #[tokio::test]
745    async fn test_backoff_retry_exhausted_returns_last_error() {
746        let backoff = Backoff::new(Duration::from_millis(0), 2.0, 2);
747        let mut calls = 0;
748
749        let result = backoff
750            .retry(|| {
751                calls += 1;
752                async { Err::<(), _>(ErrorCategory::Retryable("still failing")) }
753            })
754            .await;
755
756        assert_eq!(result, Err("still failing"));
757        assert_eq!(calls, 3);
758    }
759
760    #[tokio::test]
761    async fn test_backoff_retry_with_zero_retries_does_not_retry() {
762        let backoff = Backoff::new(Duration::from_millis(0), 2.0, 0);
763        let mut calls = 0;
764
765        let result = backoff
766            .retry(|| {
767                calls += 1;
768                async { Err::<(), _>(ErrorCategory::Retryable("still failing")) }
769            })
770            .await;
771
772        assert_eq!(result, Err("still failing"));
773        assert_eq!(calls, 1);
774    }
775
776    #[test]
777    fn test_should_reconnect() {
778        assert!(is_recoverable_status_code(Code::Unavailable));
779        assert!(is_recoverable_status_code(Code::Internal));
780        assert!(is_recoverable_status_code(Code::Unknown));
781        assert!(is_recoverable_status_code(Code::Cancelled));
782        assert!(is_recoverable_status_code(Code::DeadlineExceeded));
783        assert!(is_recoverable_status_code(Code::ResourceExhausted));
784        assert!(is_recoverable_status_code(Code::Aborted));
785        assert!(is_recoverable_status_code(Code::DataLoss));
786        assert!(is_recoverable_status_code(Code::OutOfRange));
787
788        assert!(!is_recoverable_status_code(Code::InvalidArgument));
789        assert!(!is_recoverable_status_code(Code::NotFound));
790        assert!(!is_recoverable_status_code(Code::PermissionDenied));
791        assert!(!is_recoverable_status_code(Code::Unauthenticated));
792        assert!(!is_recoverable_status_code(Code::Unimplemented));
793        assert!(!is_recoverable_status_code(Code::FailedPrecondition));
794    }
795
796    #[test]
797    fn test_extract_slot_from_slot_update() {
798        let msg = SubscribeUpdate {
799            filters: vec![],
800            update_oneof: Some(UpdateOneof::Slot(SubscribeUpdateSlot {
801                slot: 42,
802                parent: None,
803                status: 0,
804                dead_error: None,
805            })),
806            created_at: None,
807        };
808        assert_eq!(extract_slot(&msg), Some(42));
809    }
810
811    #[test]
812    fn test_extract_slot_from_ping() {
813        let msg = SubscribeUpdate {
814            filters: vec![],
815            update_oneof: Some(UpdateOneof::Ping(SubscribeUpdatePing {})),
816            created_at: None,
817        };
818        assert_eq!(extract_slot(&msg), None);
819    }
820
821    #[test]
822    fn test_extract_slot_none() {
823        let msg = SubscribeUpdate {
824            filters: vec![],
825            update_oneof: None,
826            created_at: None,
827        };
828        assert_eq!(extract_slot(&msg), None);
829    }
830
831    fn make_slot_msg(slot: u64, status: i32) -> SubscribeUpdate {
832        SubscribeUpdate {
833            filters: vec![],
834            update_oneof: Some(UpdateOneof::Slot(SubscribeUpdateSlot {
835                slot,
836                parent: None,
837                status,
838                dead_error: None,
839            })),
840            created_at: None,
841        }
842    }
843
844    fn make_block_meta_msg(slot: u64) -> SubscribeUpdate {
845        SubscribeUpdate {
846            filters: vec![],
847            update_oneof: Some(UpdateOneof::BlockMeta(
848                yellowstone_grpc_proto::prelude::SubscribeUpdateBlockMeta {
849                    slot,
850                    blockhash: String::new(),
851                    rewards: None,
852                    block_time: None,
853                    block_height: None,
854                    parent_slot: slot.saturating_sub(1),
855                    parent_blockhash: String::new(),
856                    executed_transaction_count: 0,
857                    entries_count: 0,
858                },
859            )),
860            created_at: None,
861        }
862    }
863
864    #[tokio::test]
865    async fn test_autoreconnect_recovers_from_recoverable_stream_error() {
866        let initial = stream::iter(vec![Err(Status::unavailable("disconnect"))]).boxed();
867        let connector = MockGrpcConnector::new(vec![ConnectPlan {
868            expected_from_slot: Some(None),
869            result: Ok(vec![Ok(make_slot_msg(42, 0))]),
870        }]);
871
872        let mut auto = AutoReconnect::new(
873            DedupStream::new(initial, DedupState::default()),
874            connector.clone(),
875            request_state(SubscribeRequest::default()),
876            backoff_with_retries(1),
877        );
878
879        let next = auto.next().await;
880        let slot = next
881            .expect("expected one item")
882            .expect("expected successful reconnect message");
883        assert_eq!(extract_slot(&slot), Some(42));
884        assert_eq!(connector.calls(), vec![None]);
885    }
886
887    #[tokio::test]
888    async fn test_autoreconnect_no_reconnect_when_max_retries_zero() {
889        let initial = stream::iter(vec![Err(Status::unavailable("disconnect"))]).boxed();
890        let connector = MockGrpcConnector::new(vec![]);
891
892        let mut auto = AutoReconnect::new(
893            DedupStream::new(initial, DedupState::default()),
894            connector.clone(),
895            request_state(SubscribeRequest::default()),
896            backoff_with_retries(0),
897        );
898
899        let first = auto.next().await.expect("expected one item");
900        assert!(first.is_err());
901        assert_eq!(
902            first.expect_err("expected recoverable error").code(),
903            Code::Unavailable
904        );
905        assert_eq!(connector.calls().len(), 0);
906    }
907
908    #[test]
909    fn test_status_codes_recoverable() {
910        for code in [
911            Code::Unavailable,
912            Code::Aborted,
913            Code::Internal,
914            Code::Unknown,
915        ] {
916            let status = Status::new(code, "test");
917            let err = GeyserGrpcClientError::TonicStatus(status);
918            assert!(
919                is_recoverable_client_error(&err),
920                "expected {:?} to be recoverable",
921                code
922            );
923        }
924    }
925
926    #[test]
927    fn test_status_codes_unrecoverable() {
928        for code in [
929            Code::InvalidArgument,
930            Code::PermissionDenied,
931            Code::Unauthenticated,
932            Code::NotFound,
933        ] {
934            let status = Status::new(code, "test");
935            let err = GeyserGrpcClientError::TonicStatus(status);
936            assert!(
937                !is_recoverable_client_error(&err),
938                "expected {:?} to be unrecoverable",
939                code
940            );
941        }
942    }
943
944    #[tokio::test]
945    async fn test_autoreconnect_passes_checkpoint_as_from_slot() {
946        let initial = stream::iter(vec![Err(Status::unavailable("disconnect"))]).boxed();
947        let connector = MockGrpcConnector::new(vec![ConnectPlan {
948            expected_from_slot: Some(Some(77)),
949            result: Ok(vec![Ok(make_slot_msg(90, 0))]),
950        }]);
951
952        let mut auto = AutoReconnect::new(
953            DedupStream::new(initial, DedupState::default()),
954            connector.clone(),
955            request_state(SubscribeRequest::default()),
956            backoff_with_retries(1),
957        );
958        auto.last_checkpoint = Some(77);
959
960        let msg = auto
961            .next()
962            .await
963            .expect("expected one item")
964            .expect("expected message after reconnect");
965        assert_eq!(extract_slot(&msg), Some(90));
966        assert_eq!(connector.calls(), vec![Some(77)]);
967    }
968
969    #[tokio::test]
970    async fn test_autoreconnect_dedup_survives_reconnect() {
971        let initial = stream::iter(vec![
972            Ok(make_slot_msg(100, 0)),
973            Err(Status::unavailable("disconnect")),
974        ])
975        .boxed();
976
977        let connector = MockGrpcConnector::new(vec![ConnectPlan {
978            expected_from_slot: None,
979            result: Ok(vec![
980                Ok(make_slot_msg(100, 0)), // duplicate — should be filtered
981                Ok(make_slot_msg(101, 0)), // new — should pass through
982            ]),
983        }]);
984
985        let mut auto = AutoReconnect::new(
986            DedupStream::new(initial, DedupState::default()),
987            connector.clone(),
988            request_state(SubscribeRequest::default()),
989            backoff_with_retries(1),
990        );
991
992        let msg1 = auto
993            .next()
994            .await
995            .expect("expected item")
996            .expect("expected ok");
997        assert_eq!(extract_slot(&msg1), Some(100));
998
999        let msg2 = auto
1000            .next()
1001            .await
1002            .expect("expected item")
1003            .expect("expected ok");
1004        assert_eq!(extract_slot(&msg2), Some(101));
1005    }
1006
1007    #[tokio::test]
1008    async fn test_autoreconnect_checkpoint_buffer() {
1009        let block_meta_msg = make_block_meta_msg(100);
1010
1011        let initial = stream::iter(vec![
1012            Ok(block_meta_msg),
1013            Err(Status::unavailable("disconnect")),
1014        ])
1015        .boxed();
1016
1017        let connector = MockGrpcConnector::new(vec![ConnectPlan {
1018            expected_from_slot: Some(Some(100 - CHECKPOINT_SLOT_BUFFER)),
1019            result: Ok(vec![Ok(make_slot_msg(105, 0))]),
1020        }]);
1021
1022        let mut auto = AutoReconnect::new(
1023            DedupStream::new(initial, DedupState::default()),
1024            connector.clone(),
1025            request_state(SubscribeRequest::default()),
1026            backoff_with_retries(1),
1027        );
1028
1029        let msg1 = auto
1030            .next()
1031            .await
1032            .expect("expected item")
1033            .expect("expected ok");
1034        assert_eq!(extract_slot(&msg1), Some(100));
1035
1036        let msg2 = auto
1037            .next()
1038            .await
1039            .expect("expected item")
1040            .expect("expected ok");
1041        assert_eq!(extract_slot(&msg2), Some(105));
1042        assert_eq!(connector.calls(), vec![Some(100 - CHECKPOINT_SLOT_BUFFER)]);
1043    }
1044
1045    #[tokio::test]
1046    async fn test_autoreconnect_unrecoverable_error_stops_stream() {
1047        let initial = stream::iter(vec![Err(Status::invalid_argument("bad filter"))]).boxed();
1048
1049        let connector = MockGrpcConnector::new(vec![]);
1050
1051        let mut auto = AutoReconnect::new(
1052            DedupStream::new(initial, DedupState::default()),
1053            connector.clone(),
1054            request_state(SubscribeRequest::default()),
1055            backoff_with_retries(1),
1056        );
1057
1058        let result = auto.next().await.expect("expected item");
1059        assert!(result.is_err());
1060        assert_eq!(
1061            result.expect_err("expected error").code(),
1062            Code::InvalidArgument
1063        );
1064
1065        assert!(auto.next().await.is_none());
1066        assert_eq!(connector.calls().len(), 0);
1067    }
1068
1069    #[tokio::test]
1070    async fn test_no_checkpoint_when_no_block_meta() {
1071        // Stream emits only account updates, no block_meta
1072        let account_msg = SubscribeUpdate {
1073            filters: vec![],
1074            update_oneof: Some(UpdateOneof::Account(SubscribeUpdateAccount {
1075                account: Some(SubscribeUpdateAccountInfo {
1076                    pubkey: vec![1; 32],
1077                    lamports: 100,
1078                    owner: vec![0; 32],
1079                    executable: false,
1080                    rent_epoch: 0,
1081                    data: vec![].into(),
1082                    write_version: 1,
1083                    txn_signature: Some(vec![0; 64]),
1084                }),
1085                slot: 100,
1086                is_startup: false,
1087            })),
1088            created_at: None,
1089        };
1090
1091        let initial = stream::iter(vec![
1092            Ok(account_msg),
1093            Err(Status::unavailable("disconnect")),
1094        ])
1095        .boxed();
1096
1097        let connector = MockGrpcConnector::new(vec![ConnectPlan {
1098            expected_from_slot: Some(None), // <-- checkpoint should be None
1099            result: Ok(vec![Ok(make_slot_msg(101, 0))]),
1100        }]);
1101
1102        let mut auto = AutoReconnect::new(
1103            DedupStream::new(initial, DedupState::default()),
1104            connector.clone(),
1105            request_state(SubscribeRequest::default()),
1106            backoff_with_retries(1),
1107        );
1108
1109        // Consume account message
1110        let _ = auto.next().await;
1111
1112        // Reconnect happens, verify from_slot is None (not Some(100))
1113        let _ = auto.next().await;
1114
1115        assert_eq!(
1116            connector.calls(),
1117            vec![None],
1118            "from_slot should be None when no block_meta received"
1119        );
1120    }
1121
1122    #[tokio::test]
1123    async fn test_checkpoint_updates_on_block_meta() {
1124        let block_meta_msg = SubscribeUpdate {
1125            filters: vec![],
1126            update_oneof: Some(UpdateOneof::BlockMeta(SubscribeUpdateBlockMeta {
1127                slot: 100,
1128                ..Default::default()
1129            })),
1130            created_at: None,
1131        };
1132
1133        let initial = stream::iter(vec![
1134            Ok(block_meta_msg),
1135            Err(Status::unavailable("disconnect")),
1136        ])
1137        .boxed();
1138
1139        let connector = MockGrpcConnector::new(vec![ConnectPlan {
1140            expected_from_slot: Some(Some(98)), // 100 - CHECKPOINT_SLOT_BUFFER (2)
1141            result: Ok(vec![Ok(make_slot_msg(101, 0))]),
1142        }]);
1143
1144        let mut auto = AutoReconnect::new(
1145            DedupStream::new(initial, DedupState::default()),
1146            connector.clone(),
1147            request_state(SubscribeRequest::default()),
1148            backoff_with_retries(1),
1149        );
1150
1151        // Consume block_meta — should set checkpoint
1152        let _ = auto.next().await;
1153
1154        // Reconnect happens
1155        let _ = auto.next().await;
1156
1157        assert_eq!(
1158            connector.calls(),
1159            vec![Some(98)],
1160            "from_slot should be checkpoint - buffer"
1161        );
1162    }
1163
1164    #[tokio::test]
1165    async fn test_reconnect_exhausts_retries_then_stops() {
1166        let initial = stream::iter(vec![Err(Status::unavailable("disconnect"))]).boxed();
1167
1168        // Connector fails every reconnect attempt
1169        let connector = MockGrpcConnector::new(vec![
1170            ConnectPlan {
1171                expected_from_slot: Some(None),
1172                result: Err(GeyserGrpcClientError::TonicStatus(Status::unavailable(
1173                    "still down",
1174                ))),
1175            },
1176            ConnectPlan {
1177                expected_from_slot: Some(None),
1178                result: Err(GeyserGrpcClientError::TonicStatus(Status::unavailable(
1179                    "still down",
1180                ))),
1181            },
1182        ]);
1183
1184        let mut auto = AutoReconnect::new(
1185            DedupStream::new(initial, DedupState::default()),
1186            connector.clone(),
1187            request_state(SubscribeRequest::default()),
1188            backoff_with_retries(2), // 2 retries allowed
1189        );
1190
1191        // Should get error after retries exhausted
1192        let result = auto.next().await;
1193        assert!(result.is_some());
1194        assert!(result.unwrap().is_err());
1195
1196        // Stream should end
1197        assert!(auto.next().await.is_none());
1198    }
1199}