Skip to main content

pg_walstream/
retry.rs

1//! Connection retry logic with exponential backoff
2//!
3//! This module provides retry mechanisms for PostgreSQL replication connections,
4//! with configurable backoff strategies and error categorization.
5
6use crate::connection::PgReplicationConnection;
7use crate::error::{ReplicationError, Result};
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9use tracing::{debug, error, info};
10
11/// Configuration for retry logic
12///
13/// Defines the parameters for exponential backoff retry behavior when
14/// connecting to PostgreSQL or recovering from transient failures.
15///
16/// # Example
17///
18/// ```
19/// use pg_walstream::RetryConfig;
20/// use std::time::Duration;
21///
22/// // Use default configuration (5 attempts, 1s to 60s delays)
23/// let config = RetryConfig::default();
24///
25/// // Or create custom configuration
26/// let custom_config = RetryConfig {
27///     max_attempts: 10,
28///     initial_delay: Duration::from_millis(500),
29///     max_delay: Duration::from_secs(30),
30///     multiplier: 2.0,
31///     max_duration: Duration::from_secs(600),
32///     jitter: true,
33/// };
34/// ```
35#[derive(Debug, Copy, Clone)]
36pub struct RetryConfig {
37    /// Maximum number of retry attempts before giving up
38    pub max_attempts: u32,
39    /// Initial delay before the first retry
40    pub initial_delay: Duration,
41    /// Maximum delay between retries (caps exponential growth)
42    pub max_delay: Duration,
43    /// Multiplier for exponential backoff (typically 2.0)
44    pub multiplier: f64,
45    /// Maximum total duration for all retry attempts
46    pub max_duration: Duration,
47    /// Whether to add random jitter to delays (reduces thundering herd)
48    pub jitter: bool,
49}
50
51impl Default for RetryConfig {
52    fn default() -> Self {
53        Self {
54            max_attempts: 5,
55            initial_delay: Duration::from_secs(1),
56            max_delay: Duration::from_secs(60),
57            multiplier: 2.0,
58            max_duration: Duration::from_secs(300),
59            jitter: true,
60        }
61    }
62}
63
64/// Custom exponential backoff implementation
65#[derive(Debug, Clone)]
66pub struct ExponentialBackoff {
67    initial_delay: Duration,
68    max_delay: Duration,
69    multiplier: f64,
70    jitter: bool,
71    current_delay: Duration,
72    attempt: u32,
73}
74
75impl ExponentialBackoff {
76    /// Create a new exponential backoff instance
77    ///
78    /// Initializes the backoff state machine with parameters from the provided
79    /// RetryConfig. The backoff starts at `initial_delay` and grows by `multiplier`
80    /// on each call to `next_delay()`, up to `max_delay`.
81    ///
82    /// # Arguments
83    ///
84    /// * `config` - Retry configuration specifying backoff parameters
85    ///
86    /// # Example
87    ///
88    /// ```
89    /// use pg_walstream::{ExponentialBackoff, RetryConfig};
90    ///
91    /// let config = RetryConfig::default();
92    /// let mut backoff = ExponentialBackoff::new(&config);
93    ///
94    /// let delay1 = backoff.next_delay();
95    /// let delay2 = backoff.next_delay();
96    /// assert!(delay2 > delay1); // Delay increases exponentially
97    /// ```
98    pub fn new(config: &RetryConfig) -> Self {
99        Self {
100            initial_delay: config.initial_delay,
101            max_delay: config.max_delay,
102            multiplier: config.multiplier,
103            jitter: config.jitter,
104            current_delay: config.initial_delay,
105            attempt: 0,
106        }
107    }
108
109    /// Get the next delay duration
110    ///
111    /// Returns the current delay and advances the internal state for the next call.
112    /// Each call increases the delay by the configured multiplier until it reaches
113    /// the maximum delay. Optional jitter is applied to prevent thundering herd problems.
114    ///
115    /// # Returns
116    ///
117    /// Duration to wait before the next retry attempt.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use pg_walstream::{ExponentialBackoff, RetryConfig};
123    /// use std::time::Duration;
124    ///
125    /// let config = RetryConfig {
126    ///     max_attempts: 5,
127    ///     initial_delay: Duration::from_millis(100),
128    ///     max_delay: Duration::from_secs(10),
129    ///     multiplier: 2.0,
130    ///     max_duration: Duration::from_secs(60),
131    ///     jitter: false,
132    /// };
133    ///
134    /// let mut backoff = ExponentialBackoff::new(&config);
135    /// let d1 = backoff.next_delay(); // ~100ms
136    /// let d2 = backoff.next_delay(); // ~200ms
137    /// let d3 = backoff.next_delay(); // ~400ms
138    /// ```
139    pub fn next_delay(&mut self) -> Duration {
140        let delay = self.current_delay;
141
142        // Calculate next delay with exponential backoff
143        let next_delay_ms = (self.current_delay.as_millis() as f64 * self.multiplier) as u64;
144        self.current_delay = Duration::from_millis(next_delay_ms).min(self.max_delay);
145        self.attempt += 1;
146
147        // Add jitter if enabled
148        if self.jitter {
149            self.add_jitter(delay)
150        } else {
151            delay
152        }
153    }
154
155    /// Add jitter to the delay (±30% randomization)
156    fn add_jitter(&self, delay: Duration) -> Duration {
157        // Simple jitter implementation without external dependencies
158        // Use current time as a simple source of randomness
159        let nanos = SystemTime::now()
160            .duration_since(UNIX_EPOCH)
161            .unwrap_or_default()
162            .subsec_nanos();
163        let jitter_factor = 0.3;
164
165        // Calculate jitter as ±30% of the delay
166        let base_millis = delay.as_millis() as f64;
167        let jitter_range = base_millis * jitter_factor;
168        let jitter = (nanos % 1000) as f64 / 1000.0; // 0.0 to 1.0
169        let jitter_adjustment = (jitter - 0.5) * 2.0 * jitter_range; // -jitter_range to +jitter_range
170
171        let final_millis = (base_millis + jitter_adjustment).max(0.0) as u64;
172        Duration::from_millis(final_millis)
173    }
174
175    /// Reset the backoff to initial state
176    pub fn reset(&mut self) {
177        self.current_delay = self.initial_delay;
178        self.attempt = 0;
179    }
180
181    /// Get current attempt number
182    pub fn attempt(&self) -> u32 {
183        self.attempt
184    }
185}
186
187impl RetryConfig {
188    /// Create an exponential backoff policy from retry configuration
189    pub fn to_backoff(&self) -> ExponentialBackoff {
190        ExponentialBackoff::new(self)
191    }
192}
193
194/// Retry wrapper for PostgreSQL replication connection operations
195pub struct ReplicationConnectionRetry {
196    config: RetryConfig,
197    connection_string: String,
198}
199
200impl ReplicationConnectionRetry {
201    /// Create a new retry wrapper
202    pub fn new(config: RetryConfig, connection_string: String) -> Self {
203        Self {
204            config,
205            connection_string,
206        }
207    }
208
209    /// The connection string this retrier dials, reused verbatim by the
210    /// initial-snapshot helper so both connections share TLS and auth semantics.
211    #[inline]
212    pub(crate) fn connection_string(&self) -> &str {
213        &self.connection_string
214    }
215
216    /// Retry connection establishment with exponential backoff
217    ///
218    /// Attempts to establish a PostgreSQL replication connection with automatic
219    /// retry logic. Uses exponential backoff with optional jitter to handle
220    /// transient connection failures gracefully.
221    ///
222    /// # Returns
223    ///
224    /// Returns a connected `PgReplicationConnection` on success.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if:
229    /// - All retry attempts are exhausted
230    /// - Maximum duration is exceeded
231    /// - The connection string is invalid — returned immediately on the first
232    ///   attempt, without consuming the remaining attempts or the backoff.
233    ///   Other permanent errors (authentication, missing database) are still
234    ///   retried: the server reports a transient directory outage with the same
235    ///   SQLSTATE as a wrong password, so failing fast on those would turn a
236    ///   recoverable blip into an outage.
237    ///
238    /// # Example
239    ///
240    /// ```no_run
241    /// use pg_walstream::{ReplicationConnectionRetry, RetryConfig};
242    ///
243    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
244    /// let retry_handler = ReplicationConnectionRetry::new(
245    ///     RetryConfig::default(),
246    ///     "postgresql://postgres:password@localhost/mydb?replication=database".to_string(),
247    /// );
248    ///
249    /// let connection = retry_handler.connect_with_retry().await?;
250    /// # Ok(())
251    /// # }
252    /// ```
253    pub async fn connect_with_retry(&self) -> Result<PgReplicationConnection> {
254        let start_time = Instant::now();
255        let mut backoff = self.config.to_backoff();
256
257        info!("Attempting to connect to PostgreSQL with retry logic");
258
259        for attempt in 1..=self.config.max_attempts {
260            debug!(
261                "Attempting PostgreSQL connection (attempt {}/{})",
262                attempt, self.config.max_attempts
263            );
264
265            // Check if we've exceeded the maximum duration
266            if start_time.elapsed() >= self.config.max_duration {
267                error!(
268                    "Connection attempts exceeded maximum duration ({:?})",
269                    self.config.max_duration
270                );
271                return Err(ReplicationError::connection(format!(
272                    "Connection attempts exceeded maximum duration of {:?}",
273                    self.config.max_duration
274                )));
275            }
276
277            match PgReplicationConnection::connect(&self.connection_string) {
278                Ok(conn) => {
279                    let elapsed = start_time.elapsed();
280                    info!(
281                        "Successfully connected to PostgreSQL on attempt {} after {:?}",
282                        attempt, elapsed
283                    );
284                    return Ok(conn);
285                }
286                Err(e) => {
287                    let error_msg = e.to_string();
288                    error!("Connection attempt {} failed: {}", attempt, error_msg);
289
290                    if matches!(e, ReplicationError::Config(_)) {
291                        error!("Connection string is invalid, not retrying: {error_msg}");
292                        return Err(e);
293                    }
294
295                    // If this is the last attempt, return the error
296                    if attempt >= self.config.max_attempts {
297                        let elapsed = start_time.elapsed();
298                        error!("All connection attempts failed after {:?}", elapsed);
299                        return Err(e);
300                    }
301
302                    // Wait before the next attempt
303                    let delay = backoff.next_delay();
304                    debug!("Waiting {:?} before next attempt", delay);
305                    tokio::time::sleep(delay).await;
306                }
307            }
308        }
309
310        // This should never be reached due to the loop logic above
311        let elapsed = start_time.elapsed();
312        error!("Connection failed after all attempts and {:?}", elapsed);
313        Err(ReplicationError::connection(
314            "Connection failed after all retry attempts".to_string(),
315        ))
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    /// The bug: a connection string that can never parse burned all five
324    /// attempts and ~15 s of backoff before returning the error it already had
325    /// on attempt 1. The *timing* assertion is the regression detector — an
326    /// `is_err()` assertion passes both before and after the fix.
327    #[cfg(feature = "rustls-tls")]
328    #[tokio::test]
329    async fn invalid_connection_string_returns_on_first_attempt() {
330        let retry = ReplicationConnectionRetry::new(
331            RetryConfig {
332                max_attempts: 5,
333                initial_delay: Duration::from_secs(5),
334                max_delay: Duration::from_secs(60),
335                multiplier: 2.0,
336                max_duration: Duration::from_secs(300),
337                jitter: false,
338            },
339            "postgresql://u@127.0.0.1/db?replication=database&sslmode=bogus".to_string(),
340        );
341
342        let start = Instant::now();
343        // `unwrap_err` would need `PgReplicationConnection: Debug`, which it is not.
344        let err = match retry.connect_with_retry().await {
345            Ok(_) => panic!("an unparsable conninfo must not connect"),
346            Err(e) => e,
347        };
348        let elapsed = start.elapsed();
349
350        // Not the generic "failed after all retry attempts" fallthrough, and not
351        // the max_duration error — the operator needs the actual diagnosis.
352        assert!(matches!(err, ReplicationError::Config(_)), "got {err:?}");
353        assert!(err.to_string().contains("sslmode"), "{err}");
354        assert!(
355            elapsed < Duration::from_secs(1),
356            "retried an unparsable conninfo for {elapsed:?}"
357        );
358    }
359
360    /// Negative control: a transient failure must still consume the ladder.
361    /// Fails if someone later widens the guard to `!e.is_transient()` or to
362    /// `is_permanent()` — a wrong password must keep being paced, because
363    /// PostgreSQL reports a transient LDAP/RADIUS outage with the same 28000.
364    #[cfg(feature = "rustls-tls")]
365    #[tokio::test]
366    async fn transient_error_still_consumes_the_backoff_ladder() {
367        // Bind then drop, so the port is almost certainly closed -> ECONNREFUSED.
368        let port = {
369            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
370            l.local_addr().unwrap().port()
371        };
372        let retry = ReplicationConnectionRetry::new(
373            RetryConfig {
374                max_attempts: 3,
375                initial_delay: Duration::from_millis(50),
376                max_delay: Duration::from_secs(1),
377                multiplier: 1.0,
378                max_duration: Duration::from_secs(300),
379                jitter: false,
380            },
381            format!(
382                "postgresql://u@127.0.0.1:{port}/db\
383                 ?replication=database&sslmode=disable&connect_timeout=1"
384            ),
385        );
386
387        let start = Instant::now();
388        let err = match retry.connect_with_retry().await {
389            Ok(_) => panic!("nothing should be listening on a just-released port"),
390            Err(e) => e,
391        };
392        assert!(!err.is_permanent(), "got {err:?}");
393        assert!(
394            start.elapsed() >= Duration::from_millis(100),
395            "did not back off between attempts"
396        );
397    }
398
399    #[test]
400    fn test_exponential_backoff_basic() {
401        let config = RetryConfig {
402            max_attempts: 5,
403            initial_delay: Duration::from_millis(100),
404            max_delay: Duration::from_secs(60),
405            multiplier: 2.0,
406            max_duration: Duration::from_secs(300),
407            jitter: false,
408        };
409
410        let mut backoff = ExponentialBackoff::new(&config);
411
412        // First delay should be the initial delay
413        let first_delay = backoff.next_delay();
414        assert_eq!(first_delay, Duration::from_millis(100));
415        assert_eq!(backoff.attempt(), 1);
416
417        // Second delay should be doubled
418        let second_delay = backoff.next_delay();
419        assert_eq!(second_delay, Duration::from_millis(200));
420        assert_eq!(backoff.attempt(), 2);
421
422        // Third delay should be doubled again
423        let third_delay = backoff.next_delay();
424        assert_eq!(third_delay, Duration::from_millis(400));
425        assert_eq!(backoff.attempt(), 3);
426    }
427
428    #[test]
429    fn test_exponential_backoff_max_delay() {
430        let config = RetryConfig {
431            max_attempts: 10,
432            initial_delay: Duration::from_millis(100),
433            max_delay: Duration::from_millis(500), // Low max delay for testing
434            multiplier: 2.0,
435            max_duration: Duration::from_secs(300),
436            jitter: false,
437        };
438
439        let mut backoff = ExponentialBackoff::new(&config);
440
441        // Skip to a point where we should hit the max delay
442        backoff.next_delay(); // 100ms
443        backoff.next_delay(); // 200ms
444        backoff.next_delay(); // 400ms
445        let delay = backoff.next_delay(); // Should be capped at 500ms, not 800ms
446
447        // The current delay should be capped at max_delay
448        assert!(delay <= Duration::from_millis(500));
449    }
450
451    #[test]
452    fn test_exponential_backoff_reset() {
453        let config = RetryConfig {
454            max_attempts: 5,
455            initial_delay: Duration::from_millis(100),
456            max_delay: Duration::from_secs(60),
457            multiplier: 2.0,
458            max_duration: Duration::from_secs(300),
459            jitter: false,
460        };
461
462        let mut backoff = ExponentialBackoff::new(&config);
463
464        // Make a few attempts
465        backoff.next_delay();
466        backoff.next_delay();
467        assert_eq!(backoff.attempt(), 2);
468
469        // Reset should go back to initial state
470        backoff.reset();
471        assert_eq!(backoff.attempt(), 0);
472
473        let delay = backoff.next_delay();
474        assert_eq!(delay, Duration::from_millis(100));
475        assert_eq!(backoff.attempt(), 1);
476    }
477
478    #[test]
479    fn test_exponential_backoff_jitter() {
480        let config = RetryConfig {
481            max_attempts: 5,
482            initial_delay: Duration::from_millis(100),
483            max_delay: Duration::from_secs(60),
484            multiplier: 2.0,
485            max_duration: Duration::from_secs(300),
486            jitter: true,
487        };
488
489        let mut backoff = ExponentialBackoff::new(&config);
490
491        let mut saw_variation = false;
492        let mut prev = backoff.next_delay();
493        for _ in 0..5 {
494            let delay = backoff.next_delay();
495            if delay != prev {
496                saw_variation = true;
497                break;
498            }
499            prev = delay;
500        }
501        assert!(saw_variation, "Expected jitter to vary delays");
502    }
503
504    #[test]
505    fn test_max_attempts() {
506        let config = RetryConfig {
507            max_attempts: 3,
508            initial_delay: Duration::from_millis(100),
509            max_delay: Duration::from_secs(60),
510            multiplier: 2.0,
511            max_duration: Duration::from_secs(300),
512            jitter: false,
513        };
514
515        let mut backoff = ExponentialBackoff::new(&config);
516
517        backoff.next_delay();
518        assert_eq!(backoff.attempt(), 1);
519        backoff.next_delay();
520        assert_eq!(backoff.attempt(), 2);
521        backoff.next_delay();
522        assert_eq!(backoff.attempt(), 3);
523
524        // After 3 attempts, we've reached max_attempts
525        assert_eq!(backoff.attempt(), config.max_attempts);
526    }
527
528    #[test]
529    fn test_multiplier_effect() {
530        let config = RetryConfig {
531            max_attempts: 5,
532            initial_delay: Duration::from_millis(10),
533            max_delay: Duration::from_secs(60),
534            multiplier: 3.0, // 3x multiplier
535            max_duration: Duration::from_secs(300),
536            jitter: false,
537        };
538
539        let mut backoff = ExponentialBackoff::new(&config);
540
541        assert_eq!(backoff.next_delay(), Duration::from_millis(10));
542        assert_eq!(backoff.next_delay(), Duration::from_millis(30));
543        assert_eq!(backoff.next_delay(), Duration::from_millis(90));
544    }
545
546    #[test]
547    fn test_retry_config_default() {
548        let config = RetryConfig::default();
549        assert_eq!(config.max_attempts, 5);
550        assert_eq!(config.initial_delay, Duration::from_secs(1));
551        assert_eq!(config.max_delay, Duration::from_secs(60));
552        assert_eq!(config.multiplier, 2.0);
553    }
554
555    #[test]
556    fn test_backoff_current_delay_field() {
557        let config = RetryConfig {
558            max_attempts: 5,
559            initial_delay: Duration::from_millis(100),
560            max_delay: Duration::from_secs(60),
561            multiplier: 2.0,
562            max_duration: Duration::from_secs(300),
563            jitter: false,
564        };
565
566        let mut backoff = ExponentialBackoff::new(&config);
567
568        assert_eq!(backoff.current_delay, Duration::from_millis(100));
569        backoff.next_delay();
570        assert_eq!(backoff.current_delay, Duration::from_millis(200));
571    }
572
573    #[test]
574    fn test_reset_clears_state() {
575        let config = RetryConfig {
576            max_attempts: 5,
577            initial_delay: Duration::from_millis(100),
578            max_delay: Duration::from_secs(60),
579            multiplier: 2.0,
580            max_duration: Duration::from_secs(300),
581            jitter: false,
582        };
583
584        let mut backoff = ExponentialBackoff::new(&config);
585
586        backoff.next_delay();
587        backoff.next_delay();
588
589        backoff.reset();
590
591        assert_eq!(backoff.attempt(), 0);
592        assert_eq!(backoff.current_delay, Duration::from_millis(100));
593    }
594
595    #[test]
596    fn test_to_backoff() {
597        let config = RetryConfig::default();
598        let backoff = config.to_backoff();
599        assert_eq!(backoff.attempt(), 0);
600        assert_eq!(backoff.current_delay, config.initial_delay);
601    }
602
603    #[test]
604    fn test_replication_connection_retry_new() {
605        let config = RetryConfig::default();
606        let retry = ReplicationConnectionRetry::new(
607            config,
608            "postgresql://localhost/test?replication=database".to_string(),
609        );
610        assert_eq!(
611            retry.connection_string,
612            "postgresql://localhost/test?replication=database"
613        );
614    }
615
616    #[test]
617    fn test_jitter_stays_within_bounds() {
618        let config = RetryConfig {
619            max_attempts: 50,
620            initial_delay: Duration::from_millis(1000),
621            max_delay: Duration::from_secs(60),
622            multiplier: 1.0, // Keep delay constant so we can measure jitter range
623            max_duration: Duration::from_secs(300),
624            jitter: true,
625        };
626
627        let mut backoff = ExponentialBackoff::new(&config);
628
629        // Run many iterations and check all are within ±30% of 1000ms
630        for _ in 0..20 {
631            let delay = backoff.next_delay();
632            let millis = delay.as_millis() as f64;
633            assert!(
634                (700.0..=1300.0).contains(&millis),
635                "Jitter delay {millis}ms is outside ±30% of 1000ms"
636            );
637            // Reset to keep base delay constant
638            backoff.current_delay = Duration::from_millis(1000);
639        }
640    }
641
642    #[test]
643    fn test_retry_config_clone_and_debug() {
644        let config = RetryConfig::default();
645        let cloned = config;
646        assert_eq!(cloned.max_attempts, config.max_attempts);
647        assert_eq!(cloned.initial_delay, config.initial_delay);
648        assert_eq!(cloned.max_delay, config.max_delay);
649
650        // Test Debug impl
651        let debug_str = format!("{:?}", config);
652        assert!(debug_str.contains("RetryConfig"));
653    }
654
655    #[test]
656    fn test_exponential_backoff_clone_debug() {
657        let config = RetryConfig::default();
658        let backoff = ExponentialBackoff::new(&config);
659        let cloned = backoff.clone();
660        assert_eq!(cloned.attempt(), backoff.attempt());
661
662        let debug_str = format!("{:?}", backoff);
663        assert!(debug_str.contains("ExponentialBackoff"));
664    }
665
666    #[test]
667    fn test_max_delay_cap_with_jitter() {
668        let config = RetryConfig {
669            max_attempts: 20,
670            initial_delay: Duration::from_secs(30),
671            max_delay: Duration::from_secs(60),
672            multiplier: 2.0,
673            max_duration: Duration::from_secs(300),
674            jitter: true,
675        };
676
677        let mut backoff = ExponentialBackoff::new(&config);
678
679        // Advance past max delay
680        for _ in 0..10 {
681            let delay = backoff.next_delay();
682            // Even with jitter, delay should not exceed max_delay * 1.3
683            let max_allowed = Duration::from_secs(60).as_millis() as f64 * 1.3;
684            assert!(
685                (delay.as_millis() as f64) <= max_allowed + 1.0,
686                "Delay {:?} exceeds max allowed with jitter",
687                delay
688            );
689        }
690    }
691
692    #[test]
693    fn test_add_jitter_small_delay() {
694        let config = RetryConfig {
695            max_attempts: 5,
696            initial_delay: Duration::from_millis(1),
697            max_delay: Duration::from_secs(60),
698            multiplier: 1.0,
699            max_duration: Duration::from_secs(300),
700            jitter: true,
701        };
702
703        let mut backoff = ExponentialBackoff::new(&config);
704        // Very small delay - jitter should not go negative
705        let delay = backoff.next_delay();
706        assert!(delay.as_millis() <= 2); // 1ms ± 30% = 0.7ms to 1.3ms
707    }
708
709    #[test]
710    fn test_backoff_max_delay_capping() {
711        let config = RetryConfig {
712            max_attempts: 10,
713            initial_delay: Duration::from_secs(50),
714            max_delay: Duration::from_secs(60),
715            multiplier: 2.0,
716            max_duration: Duration::from_secs(300),
717            jitter: false,
718        };
719
720        let mut backoff = ExponentialBackoff::new(&config);
721
722        let d1 = backoff.next_delay(); // 50s
723        assert_eq!(d1, Duration::from_secs(50));
724        let d2 = backoff.next_delay(); // 100s => capped to 60s
725        assert_eq!(d2, Duration::from_secs(60));
726        let d3 = backoff.next_delay(); // still 60s (capped)
727        assert_eq!(d3, Duration::from_secs(60));
728    }
729
730    /// The snapshot helper derives its reader conninfo from this string, so it
731    /// must be returned verbatim — any normalisation here would silently change
732    /// the TLS or auth settings of the second connection.
733    #[test]
734    fn connection_string_is_returned_verbatim() {
735        let conninfo = "postgresql://u:p@h:5432/db?replication=database&sslmode=verify-full";
736        let retry = ReplicationConnectionRetry::new(RetryConfig::default(), conninfo.to_string());
737        assert_eq!(retry.connection_string(), conninfo);
738    }
739}