Skip to main content

ferogram_mtsender/
retry.rs

1/*
2 * Copyright (c) 2026 Ankit Chaubey <ankitchaubey.dev@gmail.com>
3 * https://github.com/ankit-chaubey
4 *
5 * Project: ferogram
6 * Website: https://ferogram.dev
7 *
8 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
9 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
10 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
11 * This file may not be copied, modified, or distributed except according
12 * to those terms.
13 */
14
15use std::num::NonZeroU32;
16use std::ops::ControlFlow;
17use std::sync::Arc;
18use std::time::Duration;
19
20use tokio::time::sleep;
21
22use crate::errors::InvocationError;
23
24// RetryPolicy trait
25
26/// Controls how the client reacts when an RPC call fails.
27///
28/// Implement this trait to provide custom flood-wait handling, circuit
29/// breakers, or exponential back-off.
30pub trait RetryPolicy: Send + Sync + 'static {
31    /// Decide whether to retry the failed request.
32    ///
33    /// Return `ControlFlow::Continue(delay)` to sleep `delay` and retry.
34    /// Return `ControlFlow::Break(())` to propagate `ctx.error` to the caller.
35    fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration>;
36}
37
38/// Context passed to [`RetryPolicy::should_retry`] on each failure.
39pub struct RetryContext {
40    /// Number of times this request has failed (starts at 1).
41    pub fail_count: NonZeroU32,
42    /// Total time already slept for this request across all prior retries.
43    pub slept_so_far: Duration,
44    /// The most recent error.
45    pub error: InvocationError,
46}
47
48// Built-in policies
49
50/// Never retry: propagate every error immediately.
51pub struct NoRetries;
52
53impl RetryPolicy for NoRetries {
54    fn should_retry(&self, _: &RetryContext) -> ControlFlow<(), Duration> {
55        ControlFlow::Break(())
56    }
57}
58
59/// Automatically sleep on `FLOOD_WAIT` and retry once on transient I/O errors.
60///
61/// Default retry policy. Sleeps on `FLOOD_WAIT`, backs off on I/O errors.
62///
63/// ```rust
64/// # use ferogram_mtsender::AutoSleep;
65/// let policy = AutoSleep {
66/// threshold: std::time::Duration::from_secs(60),
67/// io_errors_as_flood_of: Some(std::time::Duration::from_secs(1)),
68/// };
69/// ```
70pub struct AutoSleep {
71    /// Maximum flood-wait the library will automatically sleep through.
72    ///
73    /// If Telegram asks us to wait longer than this, the error is propagated.
74    pub threshold: Duration,
75
76    /// If `Some(d)`, treat the first I/O error as a `d`-second flood wait
77    /// and retry once.  `None` propagates I/O errors immediately.
78    pub io_errors_as_flood_of: Option<Duration>,
79}
80
81impl Default for AutoSleep {
82    fn default() -> Self {
83        Self {
84            threshold: Duration::from_secs(60),
85            io_errors_as_flood_of: Some(Duration::from_secs(1)),
86        }
87    }
88}
89
90/// Add deterministic ±`max_jitter_secs` jitter to `base`.
91///
92/// Uses a fast integer hash of `seed` (the fail count) so no `rand` crate is
93/// needed. Different bots have different fail counts at any given moment, so
94/// the spread is sufficient to avoid thundering-herd on simultaneous FLOOD_WAITs.
95fn jitter_duration(base: Duration, seed: u32, max_jitter_secs: u64) -> Duration {
96    // Murmur3-inspired finalizer.
97    let h = {
98        let mut v = seed as u64 ^ 0x9e37_79b9_7f4a_7c15;
99        v ^= v >> 30;
100        v = v.wrapping_mul(0xbf58_476d_1ce4_e5b9);
101        v ^= v >> 27;
102        v = v.wrapping_mul(0x94d0_49bb_1331_11eb);
103        v ^= v >> 31;
104        v
105    };
106    // Map into [-max_jitter_secs, +max_jitter_secs] in milliseconds.
107    let range_ms = max_jitter_secs * 1000 * 2 + 1;
108    let jitter_ms = (h % range_ms) as i64 - (max_jitter_secs * 1000) as i64;
109    let base_ms = base.as_millis() as i64;
110    let final_ms = (base_ms + jitter_ms).max(0) as u64;
111    Duration::from_millis(final_ms)
112}
113
114impl RetryPolicy for AutoSleep {
115    fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration> {
116        match &ctx.error {
117            // FLOOD_WAIT: sleep as long as Telegram asks, plus ±2 s jitter.
118            // Jitter spreads retries across clients that all hit the same limit
119            // simultaneously (e.g. after a server-side rate-limit window resets).
120            InvocationError::Rpc(rpc) if rpc.code == 420 && rpc.name == "FLOOD_WAIT" => {
121                let secs = rpc.value.unwrap_or(0) as u64;
122                if secs <= self.threshold.as_secs() {
123                    let delay = jitter_duration(Duration::from_secs(secs), ctx.fail_count.get(), 2);
124                    tracing::debug!(
125                        "[ferogram::retry] FLOOD_WAIT_{secs}: sleeping {delay:?} before retrying"
126                    );
127                    ControlFlow::Continue(delay)
128                } else {
129                    ControlFlow::Break(())
130                }
131            }
132
133            // SLOWMODE_WAIT: same semantics as FLOOD_WAIT; very common in
134            // group bots that send messages faster than the channel's slowmode.
135            InvocationError::Rpc(rpc) if rpc.code == 420 && rpc.name == "SLOWMODE_WAIT" => {
136                let secs = rpc.value.unwrap_or(0) as u64;
137                if secs <= self.threshold.as_secs() {
138                    let delay = jitter_duration(Duration::from_secs(secs), ctx.fail_count.get(), 2);
139                    tracing::debug!(
140                        "[ferogram::retry] SLOWMODE_WAIT_{secs}: sleeping {delay:?} before retrying"
141                    );
142                    ControlFlow::Continue(delay)
143                } else {
144                    ControlFlow::Break(())
145                }
146            }
147
148            // Transient I/O errors: back off briefly and retry once.
149            InvocationError::Io(_) if ctx.fail_count.get() <= 1 => {
150                if let Some(d) = self.io_errors_as_flood_of {
151                    tracing::debug!(
152                        "[ferogram::retry] transient I/O error (attempt {}): sleeping {d:?} before retrying",
153                        ctx.fail_count.get()
154                    );
155                    ControlFlow::Continue(d)
156                } else {
157                    ControlFlow::Break(())
158                }
159            }
160
161            _ => ControlFlow::Break(()),
162        }
163    }
164}
165
166// RetryLoop
167
168/// Drives the retry loop for a single RPC call.
169///
170/// Create one per call, then call `advance` after every failure.
171///
172/// ```rust,ignore
173/// let mut rl = RetryLoop::new(Arc::clone(&self.inner.retry_policy));
174/// loop {
175/// match self.do_rpc_call(req).await {
176///     Ok(body) => return Ok(body),
177///     Err(e)   => rl.advance(e).await?,
178/// }
179/// }
180/// ```
181///
182/// `advance` either:
183/// - sleeps the required duration and returns `Ok(())` → caller should retry, or
184/// - returns `Err(e)` → caller should propagate.
185///
186/// This is the single source of truth; previously the same loop was
187/// copy-pasted into `rpc_call_raw`, `rpc_write`, and the reconnect path.
188pub struct RetryLoop {
189    policy: Arc<dyn RetryPolicy>,
190    ctx: RetryContext,
191}
192
193impl RetryLoop {
194    /// Start a fresh retry loop against `policy`, with the failure count
195    /// and accumulated sleep time both reset.
196    pub fn new(policy: Arc<dyn RetryPolicy>) -> Self {
197        Self {
198            policy,
199            ctx: RetryContext {
200                fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
201                slept_so_far: Duration::default(),
202                error: InvocationError::Dropped,
203            },
204        }
205    }
206
207    /// Record a failure and either sleep+return-Ok (retry) or return-Err (give up).
208    ///
209    /// Mutates `self` to track cumulative state across retries.
210    pub async fn advance(&mut self, err: InvocationError) -> Result<(), InvocationError> {
211        self.ctx.error = err;
212        match self.policy.should_retry(&self.ctx) {
213            ControlFlow::Continue(delay) => {
214                sleep(delay).await;
215                self.ctx.slept_so_far += delay;
216                // saturating_add: if somehow we overflow NonZeroU32, clamp at MAX
217                self.ctx.fail_count = self.ctx.fail_count.saturating_add(1);
218                Ok(())
219            }
220            ControlFlow::Break(()) => {
221                // Move the error out so the caller doesn't have to clone it
222                Err(std::mem::replace(
223                    &mut self.ctx.error,
224                    InvocationError::Dropped,
225                ))
226            }
227        }
228    }
229}
230
231// CircuitBreaker
232
233/// Internal state of a [`CircuitBreaker`].
234#[derive(Debug)]
235enum CbState {
236    /// Normal operation: counting consecutive failures.
237    Closed { consecutive_failures: u32 },
238    /// Breaker tripped: all calls rejected until cooldown expires.
239    Open { tripped_at: std::time::Instant },
240}
241
242/// A [`RetryPolicy`] that stops retrying after `threshold` consecutive
243/// failures and stays silent for a `cooldown` window before resetting.
244///
245/// # States
246/// - **Closed** (normal): forwards calls, increments a failure counter on
247///   each error, and applies an exponential back-off up to `threshold − 1`
248///   attempts.  On the `threshold`-th consecutive failure the breaker trips.
249/// - **Open** (tripped): rejects every call immediately (`Break`) for the
250///   duration of `cooldown`.
251/// - **Reset**: once `cooldown` has elapsed the breaker closes again and
252///   the failure counter resets to zero.
253///
254/// Because [`RetryPolicy`] has no success callback the breaker cannot
255/// distinguish a successful probe from a clean run; the counter simply
256/// resets when the cooldown expires.  For a full half-open probe you can
257/// wrap `CircuitBreaker` in a custom `RetryPolicy`.
258///
259/// # Example
260/// ```rust
261/// use ferogram_mtsender::CircuitBreaker;
262/// use std::time::Duration;
263///
264/// // Trip after 5 consecutive errors; stay open for 30 s.
265/// let policy = CircuitBreaker::new(5, Duration::from_secs(30));
266/// ```
267pub struct CircuitBreaker {
268    /// Number of consecutive failures before the breaker trips.
269    threshold: u32,
270    /// How long the breaker stays open before resetting.
271    cooldown: Duration,
272    state: std::sync::Mutex<CbState>,
273}
274
275impl CircuitBreaker {
276    /// Create a new `CircuitBreaker`.
277    ///
278    /// - `threshold`: failures before the breaker trips (minimum 1).
279    /// - `cooldown`: how long the breaker stays open.
280    pub fn new(threshold: u32, cooldown: Duration) -> Self {
281        assert!(
282            threshold >= 1,
283            "CircuitBreaker threshold must be at least 1"
284        );
285        Self {
286            threshold,
287            cooldown,
288            state: std::sync::Mutex::new(CbState::Closed {
289                consecutive_failures: 0,
290            }),
291        }
292    }
293}
294
295impl RetryPolicy for CircuitBreaker {
296    fn should_retry(&self, _ctx: &RetryContext) -> ControlFlow<(), Duration> {
297        let mut state = self.state.lock().expect("lock poisoned");
298        match &*state {
299            CbState::Open { tripped_at } => {
300                if tripped_at.elapsed() >= self.cooldown {
301                    // Cooldown expired: reset to Closed, allow retry with small delay.
302                    *state = CbState::Closed {
303                        consecutive_failures: 1,
304                    };
305                    ControlFlow::Continue(Duration::from_millis(200))
306                } else {
307                    // Still open: reject immediately.
308                    ControlFlow::Break(())
309                }
310            }
311            CbState::Closed {
312                consecutive_failures,
313            } => {
314                let new_count = consecutive_failures + 1;
315                if new_count >= self.threshold {
316                    tracing::warn!(
317                        "[ferogram::retry] circuit breaker tripped after {new_count} consecutive failures; rejecting requests for {:?}",
318                        self.cooldown
319                    );
320                    *state = CbState::Open {
321                        tripped_at: std::time::Instant::now(),
322                    };
323                    ControlFlow::Break(())
324                } else {
325                    // Exponential back-off: 200 ms × 2^(n-1), capped at ~3 s.
326                    let backoff_ms = 200u64 * (1u64 << new_count.saturating_sub(1).min(4));
327                    *state = CbState::Closed {
328                        consecutive_failures: new_count,
329                    };
330                    ControlFlow::Continue(Duration::from_millis(backoff_ms))
331                }
332            }
333        }
334    }
335}
336
337// Tests
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::errors::RpcError;
343    use std::io;
344
345    fn flood(secs: u32) -> InvocationError {
346        InvocationError::Rpc(RpcError {
347            code: 420,
348            name: "FLOOD_WAIT".into(),
349            value: Some(secs),
350        })
351    }
352
353    fn io_err() -> InvocationError {
354        InvocationError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "reset"))
355    }
356
357    fn rpc(code: i32, name: &str, value: Option<u32>) -> InvocationError {
358        InvocationError::Rpc(RpcError {
359            code,
360            name: name.into(),
361            value,
362        })
363    }
364
365    // NoRetries
366
367    #[test]
368    fn no_retries_always_breaks() {
369        let policy = NoRetries;
370        let ctx = RetryContext {
371            fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
372            slept_so_far: Duration::default(),
373            error: flood(10),
374        };
375        assert!(matches!(policy.should_retry(&ctx), ControlFlow::Break(())));
376    }
377
378    // AutoSleep
379
380    #[test]
381    fn autosleep_retries_flood_under_threshold() {
382        let policy = AutoSleep::default(); // threshold = 60s
383        let ctx = RetryContext {
384            fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
385            slept_so_far: Duration::default(),
386            error: flood(30),
387        };
388        match policy.should_retry(&ctx) {
389            // Jitter of ±2s is applied; accept 28..=32 s.
390            ControlFlow::Continue(d) => {
391                let secs = d.as_secs_f64();
392                assert!(
393                    secs >= 28.0 && secs <= 32.0,
394                    "expected 28-32s delay (jitter), got {secs:.3}s"
395                );
396            }
397            other => panic!("expected Continue, got {other:?}"),
398        }
399    }
400
401    #[test]
402    fn autosleep_breaks_flood_over_threshold() {
403        let policy = AutoSleep::default(); // threshold = 60s
404        let ctx = RetryContext {
405            fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
406            slept_so_far: Duration::default(),
407            error: flood(120),
408        };
409        assert!(matches!(policy.should_retry(&ctx), ControlFlow::Break(())));
410    }
411
412    #[test]
413    fn autosleep_second_flood_retry_is_honoured() {
414        let policy = AutoSleep::default();
415        let ctx = RetryContext {
416            fail_count: NonZeroU32::new(2).expect("2 is nonzero"),
417            slept_so_far: Duration::from_secs(30),
418            error: flood(30),
419        };
420        match policy.should_retry(&ctx) {
421            // Jitter of ±2s; accept 28..=32 s.
422            ControlFlow::Continue(d) => {
423                let secs = d.as_secs_f64();
424                assert!(
425                    secs >= 28.0 && secs <= 32.0,
426                    "expected 28-32s on second FLOOD_WAIT, got {secs:.3}s"
427                );
428            }
429            other => panic!("expected Continue on second FLOOD_WAIT, got {other:?}"),
430        }
431    }
432
433    #[test]
434    fn autosleep_retries_io_once() {
435        let policy = AutoSleep::default();
436        let ctx = RetryContext {
437            fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
438            slept_so_far: Duration::default(),
439            error: io_err(),
440        };
441        match policy.should_retry(&ctx) {
442            ControlFlow::Continue(d) => assert_eq!(d, Duration::from_secs(1)),
443            other => panic!("expected Continue, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn autosleep_no_io_retry_after_first() {
449        let policy = AutoSleep::default();
450        let ctx = RetryContext {
451            fail_count: NonZeroU32::new(4).expect("4 is nonzero"),
452            slept_so_far: Duration::from_secs(3),
453            error: io_err(),
454        };
455        assert!(matches!(policy.should_retry(&ctx), ControlFlow::Break(())));
456    }
457
458    #[test]
459    fn autosleep_breaks_other_rpc() {
460        let policy = AutoSleep::default();
461        let ctx = RetryContext {
462            fail_count: NonZeroU32::new(1).expect("1 is nonzero"),
463            slept_so_far: Duration::default(),
464            error: rpc(400, "BAD_REQUEST", None),
465        };
466        assert!(matches!(policy.should_retry(&ctx), ControlFlow::Break(())));
467    }
468
469    // RpcError::migrate_dc_id
470
471    #[test]
472    fn migrate_dc_id_detected() {
473        let e = RpcError {
474            code: 303,
475            name: "PHONE_MIGRATE".into(),
476            value: Some(5),
477        };
478        assert_eq!(e.migrate_dc_id(), Some(5));
479    }
480
481    #[test]
482    fn network_migrate_detected() {
483        let e = RpcError {
484            code: 303,
485            name: "NETWORK_MIGRATE".into(),
486            value: Some(3),
487        };
488        assert_eq!(e.migrate_dc_id(), Some(3));
489    }
490
491    #[test]
492    fn file_migrate_detected() {
493        let e = RpcError {
494            code: 303,
495            name: "FILE_MIGRATE".into(),
496            value: Some(4),
497        };
498        assert_eq!(e.migrate_dc_id(), Some(4));
499    }
500
501    #[test]
502    fn non_migrate_is_none() {
503        let e = RpcError {
504            code: 420,
505            name: "FLOOD_WAIT".into(),
506            value: Some(30),
507        };
508        assert_eq!(e.migrate_dc_id(), None);
509    }
510
511    #[test]
512    fn migrate_falls_back_to_dc2_when_no_value() {
513        let e = RpcError {
514            code: 303,
515            name: "PHONE_MIGRATE".into(),
516            value: None,
517        };
518        assert_eq!(e.migrate_dc_id(), Some(2));
519    }
520
521    // RetryLoop
522
523    #[tokio::test]
524    async fn retry_loop_gives_up_on_no_retries() {
525        let mut rl = RetryLoop::new(Arc::new(NoRetries));
526        let err = rpc(400, "SOMETHING_WRONG", None);
527        let result = rl.advance(err).await;
528        assert!(result.is_err());
529    }
530
531    #[tokio::test]
532    async fn retry_loop_increments_fail_count() {
533        let mut rl = RetryLoop::new(Arc::new(AutoSleep {
534            threshold: Duration::from_secs(60),
535            io_errors_as_flood_of: Some(Duration::from_millis(1)),
536        }));
537        assert!(rl.advance(io_err()).await.is_ok());
538        assert!(rl.advance(io_err()).await.is_err());
539    }
540
541    // CircuitBreaker
542
543    #[test]
544    fn circuit_breaker_trips_after_threshold() {
545        let cb = CircuitBreaker::new(3, Duration::from_secs(60));
546        let ctx = |n: u32| RetryContext {
547            fail_count: NonZeroU32::new(n).unwrap(),
548            slept_so_far: Duration::default(),
549            error: rpc(500, "INTERNAL", None),
550        };
551        // First two failures: Continue (backoff)
552        assert!(matches!(cb.should_retry(&ctx(1)), ControlFlow::Continue(_)));
553        assert!(matches!(cb.should_retry(&ctx(2)), ControlFlow::Continue(_)));
554        // Third: trips the breaker → Break
555        assert!(matches!(cb.should_retry(&ctx(3)), ControlFlow::Break(())));
556        // Subsequent calls while open → Break immediately
557        assert!(matches!(cb.should_retry(&ctx(4)), ControlFlow::Break(())));
558    }
559
560    #[test]
561    fn circuit_breaker_resets_after_cooldown() {
562        let cb = CircuitBreaker::new(2, Duration::from_millis(10));
563        let ctx = |n: u32| RetryContext {
564            fail_count: NonZeroU32::new(n).unwrap(),
565            slept_so_far: Duration::default(),
566            error: rpc(500, "INTERNAL", None),
567        };
568        // Trip the breaker
569        assert!(matches!(cb.should_retry(&ctx(1)), ControlFlow::Continue(_)));
570        assert!(matches!(cb.should_retry(&ctx(2)), ControlFlow::Break(())));
571        // Wait for cooldown
572        std::thread::sleep(Duration::from_millis(20));
573        // After cooldown: breaker resets → Continue again
574        assert!(matches!(cb.should_retry(&ctx(1)), ControlFlow::Continue(_)));
575    }
576}