Skip to main content

machi_llm/
retry.rs

1//! Retry classification and backoff for sampler decorators.
2//!
3//! Maturity: **core**
4//!
5//! Pure logic — no I/O. [`crate::retrying::RetryingSampler`] applies the policy.
6
7#![allow(
8    clippy::duration_suboptimal_units,
9    reason = "policy constants use secs for stable readability across toolchains"
10)]
11
12use std::time::Duration;
13
14use machi_types::{ErrorCode, MachiError, RetryClass};
15
16/// After this many 429 retries, escalate instead of waiting again.
17pub const RATE_LIMIT_RETRY_THRESHOLD: u32 = 2;
18
19/// Default max attempts (including the first try). Attempt index reaching this is fatal.
20pub const DEFAULT_MAX_ATTEMPTS: u32 = 15;
21
22/// Cap for exponential backoff (and non-429 Retry-After clamp).
23pub const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30);
24
25/// Cap when honoring full `Retry-After` on 429.
26pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
27
28/// Base exponential backoff start (attempt 1 → 2s).
29const BACKOFF_BASE_SECS: u64 = 2;
30
31/// Configurable retry policy for [`crate::retrying::RetryingSampler`].
32#[derive(Debug, Clone, Copy)]
33pub struct RetryPolicy {
34    /// Max sample attempts (including first). Default [`DEFAULT_MAX_ATTEMPTS`].
35    pub max_attempts: u32,
36    /// Max 429 retries (separate budget). Default [`RATE_LIMIT_RETRY_THRESHOLD`].
37    pub rate_limit_max: u32,
38    /// Cap for exponential backoff.
39    pub max_backoff: Duration,
40    /// Cap for full Retry-After waits on 429.
41    pub max_retry_after: Duration,
42    /// Disable jitter (tests).
43    pub jitter: bool,
44}
45
46impl Default for RetryPolicy {
47    fn default() -> Self {
48        Self {
49            max_attempts: DEFAULT_MAX_ATTEMPTS,
50            rate_limit_max: RATE_LIMIT_RETRY_THRESHOLD,
51            max_backoff: MAX_RETRY_BACKOFF,
52            max_retry_after: MAX_RETRY_AFTER,
53            jitter: true,
54        }
55    }
56}
57
58impl RetryPolicy {
59    /// Test-friendly policy: few attempts, no jitter, short caps.
60    #[must_use]
61    pub fn for_tests() -> Self {
62        Self {
63            max_attempts: 5,
64            rate_limit_max: 2,
65            max_backoff: Duration::ZERO,
66            max_retry_after: Duration::ZERO,
67            jitter: false,
68        }
69    }
70}
71
72/// Decision from the classifier.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum RetryDecision {
75    /// Do not retry; surface the error.
76    Fatal,
77    /// Sleep then retry.
78    Retry {
79        /// Backoff before next attempt.
80        backoff: Duration,
81        /// Human reason for telemetry / [`crate::stream::SampleEvent::Retrying`].
82        reason: String,
83    },
84}
85
86/// Context for one failed attempt.
87#[derive(Debug, Clone, Copy)]
88pub struct RetryContext {
89    /// 0-based attempt index of the failure (0 = first try failed).
90    pub attempt: u32,
91    /// How many 429 retries have already been consumed.
92    pub rate_limit_retries: u32,
93    /// Optional `Retry-After` from the provider (seconds).
94    pub retry_after: Option<Duration>,
95    /// Optional `x-should-retry` header (`Some(false)` is fatal).
96    pub x_should_retry: Option<bool>,
97    /// HTTP status when known.
98    pub http_status: Option<u16>,
99}
100
101/// Classify an HTTP status for the edge-client policy (W2.1).
102///
103/// - `x-should-retry: false` → fatal  
104/// - 400/401/403/404/422 → fatal  
105/// - 525/526 → fatal  
106/// - 429 → rate-limited retry  
107/// - other 5xx → retry  
108/// - else → fatal (unknown 4xx)
109#[must_use]
110pub fn classify_http_status(status: u16, x_should_retry: Option<bool>) -> HttpRetryClass {
111    if x_should_retry == Some(false) {
112        return HttpRetryClass::Fatal;
113    }
114    match status {
115        400 | 401 | 403 | 404 | 422 => HttpRetryClass::Fatal,
116        525 | 526 => HttpRetryClass::Fatal,
117        429 => HttpRetryClass::RateLimited,
118        s if (500..600).contains(&s) => HttpRetryClass::Retry,
119        _ if x_should_retry == Some(true) => HttpRetryClass::Retry,
120        _ => HttpRetryClass::Fatal,
121    }
122}
123
124/// HTTP classification outcome.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum HttpRetryClass {
127    /// Do not retry.
128    Fatal,
129    /// Retry with exponential backoff.
130    Retry,
131    /// Retry with 429 budget + Retry-After.
132    RateLimited,
133}
134
135/// Map HTTP class + status into a [`MachiError`] code.
136#[must_use]
137pub fn error_code_for_http(status: u16, class: HttpRetryClass) -> ErrorCode {
138    match (status, class) {
139        (401 | 403, _) => ErrorCode::LlmAuth,
140        (429, _) | (_, HttpRetryClass::RateLimited) => ErrorCode::LlmRateLimit,
141        _ => ErrorCode::LlmProvider,
142    }
143}
144
145/// Decide whether to retry after a [`MachiError`].
146#[must_use]
147pub fn decide_retry(policy: &RetryPolicy, err: &MachiError, ctx: &RetryContext) -> RetryDecision {
148    let next_attempt = ctx.attempt.saturating_add(1);
149    if next_attempt >= policy.max_attempts {
150        return RetryDecision::Fatal;
151    }
152
153    // Explicit server hint on the error path is not always present; honor RetryClass.
154    match err.code() {
155        ErrorCode::LlmIdleTimeout | ErrorCode::LlmTruncated | ErrorCode::LlmAuth => {
156            return RetryDecision::Fatal;
157        }
158        ErrorCode::LlmCancelled => return RetryDecision::Fatal,
159        ErrorCode::LlmEmptyResponse => {
160            let backoff = backoff_for_attempt(policy, next_attempt);
161            return RetryDecision::Retry {
162                backoff,
163                reason: "empty_response".into(),
164            };
165        }
166        ErrorCode::LlmRateLimit => {
167            if ctx.rate_limit_retries >= policy.rate_limit_max {
168                return RetryDecision::Fatal;
169            }
170            let wait = ctx
171                .retry_after
172                .unwrap_or_else(|| backoff_for_attempt(policy, next_attempt))
173                .min(policy.max_retry_after);
174            return RetryDecision::Retry {
175                backoff: wait,
176                reason: "rate_limited".into(),
177            };
178        }
179        ErrorCode::LlmProvider if err.retry_class() == RetryClass::Backoff => {
180            let backoff = ctx
181                .retry_after
182                .map(|d| d.min(policy.max_backoff))
183                .unwrap_or_else(|| backoff_for_attempt(policy, next_attempt));
184            return RetryDecision::Retry {
185                backoff,
186                reason: "provider".into(),
187            };
188        }
189        _ => {}
190    }
191
192    if let Some(status) = ctx.http_status {
193        match classify_http_status(status, ctx.x_should_retry) {
194            HttpRetryClass::Fatal => return RetryDecision::Fatal,
195            HttpRetryClass::RateLimited => {
196                if ctx.rate_limit_retries >= policy.rate_limit_max {
197                    return RetryDecision::Fatal;
198                }
199                let wait = ctx
200                    .retry_after
201                    .unwrap_or_else(|| backoff_for_attempt(policy, next_attempt))
202                    .min(policy.max_retry_after);
203                return RetryDecision::Retry {
204                    backoff: wait,
205                    reason: format!("http_{status}"),
206                };
207            }
208            HttpRetryClass::Retry => {
209                let backoff = backoff_for_attempt(policy, next_attempt);
210                return RetryDecision::Retry {
211                    backoff,
212                    reason: format!("http_{status}"),
213                };
214            }
215        }
216    }
217
218    if err.retry_class() == RetryClass::Backoff {
219        let backoff = backoff_for_attempt(policy, next_attempt);
220        return RetryDecision::Retry {
221            backoff,
222            reason: err.code().as_str().into(),
223        };
224    }
225
226    RetryDecision::Fatal
227}
228
229/// Exponential backoff 2^attempt seconds from 2s, capped, with optional ±20% jitter.
230#[must_use]
231pub fn backoff_for_attempt(policy: &RetryPolicy, attempt: u32) -> Duration {
232    // attempt 1 → 2s, 2 → 4s, …
233    let shift = attempt.saturating_sub(1).min(16);
234    let base_ms = (BACKOFF_BASE_SECS.saturating_mul(1000))
235        .checked_shl(shift)
236        .unwrap_or(u64::MAX)
237        .min(u64::try_from(policy.max_backoff.as_millis()).unwrap_or(u64::MAX));
238    let base = Duration::from_millis(base_ms);
239    if policy.jitter { jittered(base) } else { base }
240}
241
242fn jittered(base: Duration) -> Duration {
243    use std::hash::{Hash, Hasher};
244    use std::sync::atomic::{AtomicU64, Ordering};
245
246    static SEQ: AtomicU64 = AtomicU64::new(0);
247    let base_ms = u64::try_from(base.as_millis()).unwrap_or(u64::MAX);
248    let range = (base_ms / 5).max(1);
249    let mut hasher = std::hash::DefaultHasher::new();
250    SEQ.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
251    base_ms.hash(&mut hasher);
252    let j = hasher.finish() % (range.saturating_mul(2).saturating_add(1));
253    // ±20% without i64: pick offset in [0, 2*range] then subtract range via saturating.
254    let ms = if j >= range {
255        base_ms.saturating_add(j - range)
256    } else {
257        base_ms.saturating_sub(range - j)
258    };
259    Duration::from_millis(ms.max(1))
260}
261
262/// True when a completed response is empty (retryable `EmptyResponse`).
263#[must_use]
264pub fn is_empty_response(message: &machi_types::Message) -> bool {
265    message.tool_calls.is_empty() && message.text().trim().is_empty()
266}
267
268#[cfg(test)]
269#[allow(clippy::expect_used, reason = "unit tests")]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn fatal_4xx_table() {
275        for s in [400_u16, 401, 403, 404, 422] {
276            assert_eq!(
277                classify_http_status(s, None),
278                HttpRetryClass::Fatal,
279                "status {s}"
280            );
281        }
282    }
283
284    #[test]
285    fn retry_5xx_except_tls() {
286        assert_eq!(classify_http_status(500, None), HttpRetryClass::Retry);
287        assert_eq!(classify_http_status(503, None), HttpRetryClass::Retry);
288        assert_eq!(classify_http_status(525, None), HttpRetryClass::Fatal);
289        assert_eq!(classify_http_status(526, None), HttpRetryClass::Fatal);
290    }
291
292    #[test]
293    fn rate_limit_and_header_hint() {
294        assert_eq!(classify_http_status(429, None), HttpRetryClass::RateLimited);
295        assert_eq!(
296            classify_http_status(500, Some(false)),
297            HttpRetryClass::Fatal
298        );
299        assert_eq!(classify_http_status(418, Some(true)), HttpRetryClass::Retry);
300    }
301
302    #[test]
303    fn empty_response_is_retried() {
304        let policy = RetryPolicy::for_tests();
305        let err = MachiError::new(ErrorCode::LlmEmptyResponse, "empty");
306        let d = decide_retry(
307            &policy,
308            &err,
309            &RetryContext {
310                attempt: 0,
311                rate_limit_retries: 0,
312                retry_after: None,
313                x_should_retry: None,
314                http_status: None,
315            },
316        );
317        assert!(matches!(d, RetryDecision::Retry { .. }));
318    }
319
320    #[test]
321    fn rate_limit_budget_exhausted() {
322        let policy = RetryPolicy::for_tests();
323        let err = MachiError::new(ErrorCode::LlmRateLimit, "429");
324        let d = decide_retry(
325            &policy,
326            &err,
327            &RetryContext {
328                attempt: 0,
329                rate_limit_retries: policy.rate_limit_max,
330                retry_after: Some(Duration::from_secs(5)),
331                x_should_retry: None,
332                http_status: Some(429),
333            },
334        );
335        assert_eq!(d, RetryDecision::Fatal);
336    }
337
338    #[test]
339    fn idle_timeout_fatal() {
340        let policy = RetryPolicy::default();
341        let err = MachiError::new(ErrorCode::LlmIdleTimeout, "idle");
342        let d = decide_retry(
343            &policy,
344            &err,
345            &RetryContext {
346                attempt: 0,
347                rate_limit_retries: 0,
348                retry_after: None,
349                x_should_retry: None,
350                http_status: None,
351            },
352        );
353        assert_eq!(d, RetryDecision::Fatal);
354    }
355
356    #[test]
357    fn backoff_first_attempt_near_two_seconds_without_jitter() {
358        let policy = RetryPolicy {
359            jitter: false,
360            ..RetryPolicy::default()
361        };
362        assert_eq!(backoff_for_attempt(&policy, 1), Duration::from_secs(2));
363        assert_eq!(backoff_for_attempt(&policy, 2), Duration::from_secs(4));
364        assert_eq!(backoff_for_attempt(&policy, 10), MAX_RETRY_BACKOFF);
365    }
366}
367
368include!("http_status_matrix.rs");