1#![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
16pub const RATE_LIMIT_RETRY_THRESHOLD: u32 = 2;
18
19pub const DEFAULT_MAX_ATTEMPTS: u32 = 15;
21
22pub const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30);
24
25pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(120);
27
28const BACKOFF_BASE_SECS: u64 = 2;
30
31#[derive(Debug, Clone, Copy)]
33pub struct RetryPolicy {
34 pub max_attempts: u32,
36 pub rate_limit_max: u32,
38 pub max_backoff: Duration,
40 pub max_retry_after: Duration,
42 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum RetryDecision {
75 Fatal,
77 Retry {
79 backoff: Duration,
81 reason: String,
83 },
84}
85
86#[derive(Debug, Clone, Copy)]
88pub struct RetryContext {
89 pub attempt: u32,
91 pub rate_limit_retries: u32,
93 pub retry_after: Option<Duration>,
95 pub x_should_retry: Option<bool>,
97 pub http_status: Option<u16>,
99}
100
101#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum HttpRetryClass {
127 Fatal,
129 Retry,
131 RateLimited,
133}
134
135#[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#[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 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#[must_use]
231pub fn backoff_for_attempt(policy: &RetryPolicy, attempt: u32) -> Duration {
232 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 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#[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");