Skip to main content

polyc_runtime/
retry.rs

1//! Rate-limit-aware retry for edge platform REST clients (#795).
2//!
3//! Every edge's `*_api.rs` dials a platform REST API (Slack, Telegram, …)
4//! that can answer a write with a rate-limit signal — an HTTP
5//! `429`, usually paired with a `Retry-After` header or an equivalent
6//! body-level field. Before this module, no client recognized that signal:
7//! a rate-limited `chat.postMessage` or an approval-card `chat.update` just
8//! surfaced as an ordinary API error and the reply (or the approval-card
9//! edit) was silently dropped.
10//!
11//! [`retry_rate_limited`] is the one shared loop every `*_api.rs` wraps its
12//! attempt in. The caller decides, per attempt, whether the platform's
13//! response means "done" or "rate limited, wait this long" — this module
14//! only owns the wait-and-retry mechanics, since each platform signals a
15//! rate limit differently (status code, header, or JSON body field).
16
17use std::{future::Future, time::Duration};
18
19/// One attempt's outcome for [`retry_rate_limited`], returned by the
20/// caller's per-attempt closure.
21#[derive(Debug)]
22pub enum RetryableError<E> {
23    /// The platform signaled a rate limit. Wait `after`, then retry; `error`
24    /// is what [`retry_rate_limited`] returns if this was the last allowed
25    /// attempt.
26    RateLimited {
27        /// How long to wait before the next attempt (from the platform's
28        /// `Retry-After` or equivalent, or a caller-chosen default when the
29        /// platform didn't say).
30        after: Duration,
31        /// The error to surface if retries are exhausted while still rate
32        /// limited.
33        error: E,
34    },
35    /// Not retryable — surface immediately without waiting.
36    Fatal(E),
37}
38
39/// Runs `attempt` until it returns `Ok`, returns a
40/// [`RetryableError::Fatal`], or exhausts `max_retries` retries after
41/// repeated [`RetryableError::RateLimited`] outcomes.
42///
43/// Sleeps for the signaled `after` duration between a `RateLimited` outcome
44/// and the next attempt — honoring the platform's own backoff request
45/// (e.g. Slack's `Retry-After` header, Telegram's `retry_after`
46/// body field) rather than a fixed schedule.
47///
48/// # Errors
49///
50/// Returns the error from the attempt that ended the loop: the `Fatal`
51/// error immediately, or the last `RateLimited` error once `max_retries` is
52/// exhausted.
53pub async fn retry_rate_limited<T, E, F, Fut>(mut max_retries: u32, mut attempt: F) -> Result<T, E>
54where
55    F: FnMut() -> Fut,
56    Fut: Future<Output = Result<T, RetryableError<E>>>,
57{
58    loop {
59        match attempt().await {
60            Ok(value) => return Ok(value),
61            Err(RetryableError::Fatal(error)) => return Err(error),
62            Err(RetryableError::RateLimited { after, error }) => {
63                if max_retries == 0 {
64                    return Err(error);
65                }
66                max_retries -= 1;
67                tokio::time::sleep(after).await;
68            }
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use std::{
76        sync::atomic::{AtomicU32, Ordering},
77        time::Instant,
78    };
79
80    use super::{RetryableError, retry_rate_limited};
81
82    #[tokio::test]
83    async fn retries_once_after_rate_limit_then_succeeds() {
84        let attempts = AtomicU32::new(0);
85        let started = Instant::now();
86        let result: Result<&str, &str> = retry_rate_limited(3, || {
87            let n = attempts.fetch_add(1, Ordering::SeqCst);
88            async move {
89                if n == 0 {
90                    Err(RetryableError::RateLimited {
91                        after: std::time::Duration::from_millis(30),
92                        error: "rate limited",
93                    })
94                } else {
95                    Ok("ok")
96                }
97            }
98        })
99        .await;
100        assert_eq!(result, Ok("ok"));
101        assert_eq!(attempts.load(Ordering::SeqCst), 2);
102        assert!(
103            started.elapsed() >= std::time::Duration::from_millis(30),
104            "must actually wait out the signaled backoff before retrying"
105        );
106    }
107
108    #[tokio::test]
109    async fn fatal_error_is_not_retried() {
110        let attempts = AtomicU32::new(0);
111        let result: Result<&str, &str> = retry_rate_limited(3, || {
112            attempts.fetch_add(1, Ordering::SeqCst);
113            async { Err(RetryableError::Fatal("boom")) }
114        })
115        .await;
116        assert_eq!(result, Err("boom"));
117        assert_eq!(attempts.load(Ordering::SeqCst), 1);
118    }
119
120    #[tokio::test]
121    async fn exhausting_retries_surfaces_the_last_rate_limit_error() {
122        let attempts = AtomicU32::new(0);
123        let result: Result<&str, &str> = retry_rate_limited(2, || {
124            attempts.fetch_add(1, Ordering::SeqCst);
125            async {
126                Err(RetryableError::RateLimited {
127                    after: std::time::Duration::from_millis(1),
128                    error: "still limited",
129                })
130            }
131        })
132        .await;
133        assert_eq!(result, Err("still limited"));
134        // Initial attempt + 2 retries.
135        assert_eq!(attempts.load(Ordering::SeqCst), 3);
136    }
137}