polyc-runtime 2026.7.1

Shared Unix-coherence runtime for polychrome binaries: logging, health/metrics side-server, signals.
Documentation
//! Rate-limit-aware retry for edge platform REST clients (#795).
//!
//! Every edge's `*_api.rs` dials a platform REST API (Slack, Telegram,
//! Discord, …) that can answer a write with a rate-limit signal — an HTTP
//! `429`, usually paired with a `Retry-After` header or an equivalent
//! body-level field. Before this module, no client recognized that signal:
//! a rate-limited `chat.postMessage` or an approval-card `chat.update` just
//! surfaced as an ordinary API error and the reply (or the approval-card
//! edit) was silently dropped.
//!
//! [`retry_rate_limited`] is the one shared loop every `*_api.rs` wraps its
//! attempt in. The caller decides, per attempt, whether the platform's
//! response means "done" or "rate limited, wait this long" — this module
//! only owns the wait-and-retry mechanics, since each platform signals a
//! rate limit differently (status code, header, or JSON body field).

use std::{future::Future, time::Duration};

/// One attempt's outcome for [`retry_rate_limited`], returned by the
/// caller's per-attempt closure.
#[derive(Debug)]
pub enum RetryableError<E> {
    /// The platform signaled a rate limit. Wait `after`, then retry; `error`
    /// is what [`retry_rate_limited`] returns if this was the last allowed
    /// attempt.
    RateLimited {
        /// How long to wait before the next attempt (from the platform's
        /// `Retry-After` or equivalent, or a caller-chosen default when the
        /// platform didn't say).
        after: Duration,
        /// The error to surface if retries are exhausted while still rate
        /// limited.
        error: E,
    },
    /// Not retryable — surface immediately without waiting.
    Fatal(E),
}

/// Runs `attempt` until it returns `Ok`, returns a
/// [`RetryableError::Fatal`], or exhausts `max_retries` retries after
/// repeated [`RetryableError::RateLimited`] outcomes.
///
/// Sleeps for the signaled `after` duration between a `RateLimited` outcome
/// and the next attempt — honoring the platform's own backoff request
/// (e.g. Slack/Discord's `Retry-After` header, Telegram's `retry_after`
/// body field) rather than a fixed schedule.
///
/// # Errors
///
/// Returns the error from the attempt that ended the loop: the `Fatal`
/// error immediately, or the last `RateLimited` error once `max_retries` is
/// exhausted.
pub async fn retry_rate_limited<T, E, F, Fut>(mut max_retries: u32, mut attempt: F) -> Result<T, E>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<T, RetryableError<E>>>,
{
    loop {
        match attempt().await {
            Ok(value) => return Ok(value),
            Err(RetryableError::Fatal(error)) => return Err(error),
            Err(RetryableError::RateLimited { after, error }) => {
                if max_retries == 0 {
                    return Err(error);
                }
                max_retries -= 1;
                tokio::time::sleep(after).await;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{
        sync::atomic::{AtomicU32, Ordering},
        time::Instant,
    };

    use super::{RetryableError, retry_rate_limited};

    #[tokio::test]
    async fn retries_once_after_rate_limit_then_succeeds() {
        let attempts = AtomicU32::new(0);
        let started = Instant::now();
        let result: Result<&str, &str> = retry_rate_limited(3, || {
            let n = attempts.fetch_add(1, Ordering::SeqCst);
            async move {
                if n == 0 {
                    Err(RetryableError::RateLimited {
                        after: std::time::Duration::from_millis(30),
                        error: "rate limited",
                    })
                } else {
                    Ok("ok")
                }
            }
        })
        .await;
        assert_eq!(result, Ok("ok"));
        assert_eq!(attempts.load(Ordering::SeqCst), 2);
        assert!(
            started.elapsed() >= std::time::Duration::from_millis(30),
            "must actually wait out the signaled backoff before retrying"
        );
    }

    #[tokio::test]
    async fn fatal_error_is_not_retried() {
        let attempts = AtomicU32::new(0);
        let result: Result<&str, &str> = retry_rate_limited(3, || {
            attempts.fetch_add(1, Ordering::SeqCst);
            async { Err(RetryableError::Fatal("boom")) }
        })
        .await;
        assert_eq!(result, Err("boom"));
        assert_eq!(attempts.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn exhausting_retries_surfaces_the_last_rate_limit_error() {
        let attempts = AtomicU32::new(0);
        let result: Result<&str, &str> = retry_rate_limited(2, || {
            attempts.fetch_add(1, Ordering::SeqCst);
            async {
                Err(RetryableError::RateLimited {
                    after: std::time::Duration::from_millis(1),
                    error: "still limited",
                })
            }
        })
        .await;
        assert_eq!(result, Err("still limited"));
        // Initial attempt + 2 retries.
        assert_eq!(attempts.load(Ordering::SeqCst), 3);
    }
}