Skip to main content

kcode_telegram_request_policy/
lib.rs

1#![doc = include_str!("../Documentation.md")]
2
3use std::{future::Future, time::Duration};
4
5use teloxide::{DownloadError, RequestError};
6
7const MAX_ATTEMPTS: usize = 5;
8const INITIAL_NETWORK_BACKOFF_MILLIS: u64 = 250;
9const MAX_NETWORK_BACKOFF_MILLIS: u64 = 2_000;
10
11pub fn request_error_class(error: &RequestError) -> &'static str {
12    match error {
13        RequestError::Api(_) => "telegram_api",
14        RequestError::MigrateToChatId(_) => "telegram_migrate",
15        RequestError::RetryAfter(_) => "telegram_rate_limit",
16        RequestError::Network(network) if network.is_timeout() => "telegram_network_timeout",
17        RequestError::Network(network) if network.is_connect() => "telegram_network_connect",
18        RequestError::Network(_) => "telegram_network",
19        RequestError::InvalidJson { .. } => "telegram_invalid_json",
20        RequestError::Io(error) => io_error_class(error.kind()),
21    }
22}
23
24fn download_error_class(error: &DownloadError) -> &'static str {
25    match error {
26        DownloadError::Network(network) if network.is_timeout() => {
27            "telegram_download_network_timeout"
28        }
29        DownloadError::Network(network) if network.is_connect() => {
30            "telegram_download_network_connect"
31        }
32        DownloadError::Network(_) => "telegram_download_network",
33        DownloadError::Io(error) => io_error_class(error.kind()),
34    }
35}
36
37fn transient_io(kind: std::io::ErrorKind) -> bool {
38    matches!(
39        kind,
40        std::io::ErrorKind::TimedOut
41            | std::io::ErrorKind::ConnectionRefused
42            | std::io::ErrorKind::ConnectionReset
43            | std::io::ErrorKind::ConnectionAborted
44            | std::io::ErrorKind::NotConnected
45            | std::io::ErrorKind::BrokenPipe
46            | std::io::ErrorKind::UnexpectedEof
47            | std::io::ErrorKind::Interrupted
48            | std::io::ErrorKind::WouldBlock
49    )
50}
51
52fn network_backoff(attempt: usize) -> Duration {
53    let exponent = u32::try_from(attempt.saturating_sub(1)).unwrap_or(u32::MAX);
54    let multiplier = 2_u64.checked_pow(exponent).unwrap_or(u64::MAX);
55    Duration::from_millis(
56        INITIAL_NETWORK_BACKOFF_MILLIS
57            .saturating_mul(multiplier)
58            .min(MAX_NETWORK_BACKOFF_MILLIS),
59    )
60}
61
62fn request_retry_delay(error: &RequestError, attempt: usize) -> Option<Duration> {
63    match error {
64        RequestError::RetryAfter(delay) => Some(delay.duration()),
65        RequestError::Network(_) => Some(network_backoff(attempt)),
66        RequestError::Io(error) if transient_io(error.kind()) => Some(network_backoff(attempt)),
67        RequestError::Api(_)
68        | RequestError::MigrateToChatId(_)
69        | RequestError::InvalidJson { .. }
70        | RequestError::Io(_) => None,
71    }
72}
73
74fn download_retry_delay(error: &DownloadError, attempt: usize) -> Option<Duration> {
75    match error {
76        DownloadError::Network(_) => Some(network_backoff(attempt)),
77        DownloadError::Io(error) if transient_io(error.kind()) => Some(network_backoff(attempt)),
78        DownloadError::Io(_) => None,
79    }
80}
81
82async fn retry_operation<T, E, Attempt, AttemptFuture, Classify, Delay, Sleep, SleepFuture>(
83    operation: &'static str,
84    mut attempt_operation: Attempt,
85    classify: Classify,
86    delay: Delay,
87    sleep: Sleep,
88) -> Result<T, E>
89where
90    Attempt: FnMut() -> AttemptFuture,
91    AttemptFuture: Future<Output = Result<T, E>>,
92    Classify: Fn(&E) -> &'static str,
93    Delay: Fn(&E, usize) -> Option<Duration>,
94    Sleep: Fn(Duration) -> SleepFuture,
95    SleepFuture: Future<Output = ()>,
96{
97    for attempt in 1..=MAX_ATTEMPTS {
98        match attempt_operation().await {
99            Ok(value) => return Ok(value),
100            Err(error) => {
101                let Some(wait) = delay(&error, attempt) else {
102                    return Err(error);
103                };
104                if attempt == MAX_ATTEMPTS {
105                    return Err(error);
106                }
107                tracing::debug!(
108                    operation,
109                    attempt,
110                    error_class = classify(&error),
111                    "Transient Telegram operation failed; retrying"
112                );
113                sleep(wait).await;
114            }
115        }
116    }
117    unreachable!("the bounded Telegram retry loop always returns")
118}
119
120pub async fn retry_request<T, Attempt, AttemptFuture>(
121    operation: &'static str,
122    attempt: Attempt,
123) -> Result<T, RequestError>
124where
125    Attempt: FnMut() -> AttemptFuture,
126    AttemptFuture: Future<Output = Result<T, RequestError>>,
127{
128    retry_operation(
129        operation,
130        attempt,
131        request_error_class,
132        request_retry_delay,
133        tokio::time::sleep,
134    )
135    .await
136}
137
138pub async fn retry_download<T, Attempt, AttemptFuture>(
139    operation: &'static str,
140    attempt: Attempt,
141) -> Result<T, DownloadError>
142where
143    Attempt: FnMut() -> AttemptFuture,
144    AttemptFuture: Future<Output = Result<T, DownloadError>>,
145{
146    retry_operation(
147        operation,
148        attempt,
149        download_error_class,
150        download_retry_delay,
151        tokio::time::sleep,
152    )
153    .await
154}
155
156fn io_error_class(kind: std::io::ErrorKind) -> &'static str {
157    match kind {
158        std::io::ErrorKind::TimedOut => "io_timeout",
159        std::io::ErrorKind::ConnectionRefused => "io_connection_refused",
160        std::io::ErrorKind::ConnectionReset => "io_connection_reset",
161        std::io::ErrorKind::ConnectionAborted => "io_connection_aborted",
162        std::io::ErrorKind::NotConnected => "io_not_connected",
163        std::io::ErrorKind::BrokenPipe => "io_broken_pipe",
164        std::io::ErrorKind::UnexpectedEof => "io_unexpected_eof",
165        std::io::ErrorKind::PermissionDenied => "io_permission_denied",
166        std::io::ErrorKind::NotFound => "io_not_found",
167        _ => "io_other",
168    }
169}
170
171pub fn anyhow_error_class(error: &anyhow::Error) -> &'static str {
172    for cause in error.chain() {
173        if let Some(request_error) = cause.downcast_ref::<RequestError>() {
174            return request_error_class(request_error);
175        }
176        if let Some(download_error) = cause.downcast_ref::<DownloadError>() {
177            return download_error_class(download_error);
178        }
179        if let Some(io_error) = cause.downcast_ref::<std::io::Error>() {
180            return io_error_class(io_error.kind());
181        }
182    }
183    "local_processing"
184}
185
186#[cfg(test)]
187mod tests {
188    use std::sync::{
189        Arc,
190        atomic::{AtomicUsize, Ordering},
191    };
192
193    use teloxide::types::{ChatId, Seconds};
194
195    use super::*;
196
197    fn transient_error() -> RequestError {
198        RequestError::Io(Arc::new(std::io::Error::new(
199            std::io::ErrorKind::ConnectionReset,
200            "test-only transient failure",
201        )))
202    }
203
204    #[tokio::test]
205    async fn transient_operation_succeeds_within_five_total_attempts() {
206        let attempts = AtomicUsize::new(0);
207        let value = retry_operation(
208            "test",
209            || {
210                let attempt = attempts.fetch_add(1, Ordering::SeqCst) + 1;
211                async move {
212                    if attempt < 3 {
213                        Err(transient_error())
214                    } else {
215                        Ok("ok")
216                    }
217                }
218            },
219            request_error_class,
220            request_retry_delay,
221            |_| async {},
222        )
223        .await
224        .unwrap();
225        assert_eq!(value, "ok");
226        assert_eq!(attempts.load(Ordering::SeqCst), 3);
227    }
228
229    #[tokio::test]
230    async fn transient_operation_stops_after_five_total_attempts() {
231        let attempts = AtomicUsize::new(0);
232        let result = retry_operation(
233            "test",
234            || {
235                attempts.fetch_add(1, Ordering::SeqCst);
236                async { Err::<(), _>(transient_error()) }
237            },
238            request_error_class,
239            request_retry_delay,
240            |_| async {},
241        )
242        .await;
243        assert!(result.is_err());
244        assert_eq!(attempts.load(Ordering::SeqCst), MAX_ATTEMPTS);
245    }
246
247    #[tokio::test]
248    async fn permanent_operation_is_attempted_once() {
249        let attempts = AtomicUsize::new(0);
250        let result = retry_operation(
251            "test",
252            || {
253                attempts.fetch_add(1, Ordering::SeqCst);
254                async { Err::<(), _>(RequestError::MigrateToChatId(ChatId(-100))) }
255            },
256            request_error_class,
257            request_retry_delay,
258            |_| async {},
259        )
260        .await;
261        assert!(result.is_err());
262        assert_eq!(attempts.load(Ordering::SeqCst), 1);
263    }
264
265    #[test]
266    fn retry_after_is_respected_exactly() {
267        let delay = request_retry_delay(&RequestError::RetryAfter(Seconds::from_seconds(17)), 1);
268        assert_eq!(delay, Some(Duration::from_secs(17)));
269    }
270}