1use std::future::Future;
23use std::time::Duration;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Retryability {
28 Retry,
31 Fatal,
34}
35
36pub fn classify(err: &anyhow::Error) -> Retryability {
44 let msg = err.to_string();
45
46 if msg.contains("request failed") {
49 return Retryability::Retry;
50 }
51
52 if let Some(code) = status_code_in(&msg) {
54 if is_retryable_status(code) {
55 return Retryability::Retry;
56 }
57 return Retryability::Fatal;
58 }
59
60 Retryability::Fatal
61}
62
63fn is_retryable_status(code: u16) -> bool {
66 code == 408 || code == 429 || (500..600).contains(&code)
67}
68
69fn status_code_in(msg: &str) -> Option<u16> {
79 const PREFIXES: &[&str] = &["returned ", "inference endpoint "];
80 for prefix in PREFIXES {
81 if let Some(after) = msg.split_once(prefix).map(|(_, r)| r) {
82 if let Some(code) = after
83 .split(|c: char| !c.is_ascii_digit())
84 .find(|s: &&str| !s.is_empty())
85 .and_then(|s| s.parse().ok())
86 {
87 return Some(code);
88 }
89 }
90 }
91 None
92}
93
94#[derive(Debug, Clone)]
100pub struct RetryPolicy {
101 pub max_retries: u32,
103 pub base: Duration,
105 pub max: Duration,
107 pub jitter: bool,
109}
110
111impl Default for RetryPolicy {
112 fn default() -> Self {
116 Self {
117 max_retries: 4,
118 base: Duration::from_millis(500),
119 max: Duration::from_secs(8),
120 jitter: true,
121 }
122 }
123}
124
125impl RetryPolicy {
126 pub fn from_env() -> Self {
134 Self::from_env_or(Self::default())
135 }
136
137 pub fn from_env_or(mut base: Self) -> Self {
141 if let Some(n) = env_parse::<u32>("NEWT_HTTP_MAX_RETRIES") {
142 base.max_retries = n;
143 }
144 if let Some(ms) = env_parse::<u64>("NEWT_HTTP_BACKOFF_BASE_MS") {
145 base.base = Duration::from_millis(ms);
146 }
147 if let Some(ms) = env_parse::<u64>("NEWT_HTTP_BACKOFF_MAX_MS") {
148 base.max = Duration::from_millis(ms);
149 }
150 if let Ok(v) = std::env::var("NEWT_HTTP_JITTER") {
151 base.jitter = !matches!(
152 v.trim().to_ascii_lowercase().as_str(),
153 "0" | "false" | "off"
154 );
155 }
156 base
157 }
158
159 pub fn for_local_inference() -> Self {
168 Self::from_env_or(Self {
169 max_retries: 6,
170 base: Duration::from_secs(2),
171 max: Duration::from_secs(30),
172 jitter: true,
173 })
174 }
175
176 pub fn immediate(max_retries: u32) -> Self {
179 Self {
180 max_retries,
181 base: Duration::ZERO,
182 max: Duration::ZERO,
183 jitter: false,
184 }
185 }
186
187 pub fn delay_for(&self, attempt: u32) -> Duration {
190 let capped_ms = self.base_delay_ms(attempt);
191 if !self.jitter || capped_ms == 0 {
192 return Duration::from_millis(capped_ms);
193 }
194 let half = capped_ms / 2;
197 let span = capped_ms - half; Duration::from_millis(half + jitter_u64() % (span + 1))
199 }
200
201 fn base_delay_ms(&self, attempt: u32) -> u64 {
203 let shift = attempt.saturating_sub(1).min(31);
204 let factor = 1u64 << shift;
205 let raw = (self.base.as_millis() as u64).saturating_mul(factor);
206 raw.min(self.max.as_millis() as u64)
207 }
208}
209
210pub async fn with_backoff_notify<T, F, Fut, N>(
220 policy: &RetryPolicy,
221 mut op: F,
222 mut on_retry: N,
223) -> anyhow::Result<T>
224where
225 F: FnMut() -> Fut,
226 Fut: Future<Output = anyhow::Result<T>>,
227 N: FnMut(u32, Duration),
228{
229 let mut retries = 0u32;
230 loop {
231 match op().await {
232 Ok(value) => return Ok(value),
233 Err(err) => {
234 if classify(&err) == Retryability::Fatal || retries >= policy.max_retries {
235 return Err(err);
236 }
237 retries += 1;
238 let delay = policy.delay_for(retries);
239 on_retry(retries, delay);
240 tracing::warn!(
241 attempt = retries,
242 delay_ms = delay.as_millis() as u64,
243 error = %err,
244 "retrying inference request"
245 );
246 tokio::time::sleep(delay).await;
247 }
248 }
249 }
250}
251
252pub async fn with_backoff<T, F, Fut>(policy: &RetryPolicy, op: F) -> anyhow::Result<T>
260where
261 F: FnMut() -> Fut,
262 Fut: Future<Output = anyhow::Result<T>>,
263{
264 with_backoff_notify(policy, op, |_, _| {}).await
265}
266
267fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
269 std::env::var(key).ok()?.trim().parse().ok()
270}
271
272fn jitter_u64() -> u64 {
277 use std::sync::atomic::{AtomicU64, Ordering};
278 static STATE: AtomicU64 = AtomicU64::new(0);
279 if STATE.load(Ordering::Relaxed) == 0 {
282 let seed = std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .map(|d| d.subsec_nanos() as u64)
285 .unwrap_or(0)
286 ^ 0x9E37_79B9_7F4A_7C15;
287 STATE.store(seed | 1, Ordering::Relaxed);
288 }
289 let mut x = STATE.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed);
290 x ^= x >> 12;
291 x ^= x << 25;
292 x ^= x >> 27;
293 x.wrapping_mul(0x2545_F491_4F6C_DD1D)
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::cell::Cell;
300
301 fn err(msg: &str) -> anyhow::Error {
302 anyhow::anyhow!("{msg}")
303 }
304
305 #[test]
306 fn classify_transport_failure_is_retry() {
307 assert_eq!(
308 classify(&err("vLLM request failed: error sending request for url")),
309 Retryability::Retry
310 );
311 assert_eq!(
312 classify(&err("Ollama request failed: connection refused")),
313 Retryability::Retry
314 );
315 }
316
317 #[test]
318 fn classify_429_and_408_and_5xx_are_retry() {
319 assert_eq!(
321 classify(&err("vLLM returned 429 Too Many Requests: slow down")),
322 Retryability::Retry
323 );
324 assert_eq!(
325 classify(&err("vLLM returned 408 Request Timeout:")),
326 Retryability::Retry
327 );
328 assert_eq!(
329 classify(&err("Ollama returned 503 Service Unavailable: down")),
330 Retryability::Retry
331 );
332 assert_eq!(
333 classify(&err("vLLM returned 500 Internal Server Error: boom")),
334 Retryability::Retry
335 );
336 }
337
338 #[test]
339 fn classify_other_4xx_is_fatal() {
340 assert_eq!(
341 classify(&err("vLLM returned 400 Bad Request: nope")),
342 Retryability::Fatal
343 );
344 assert_eq!(
345 classify(&err("vLLM returned 404 Not Found:")),
346 Retryability::Fatal
347 );
348 }
349
350 #[test]
351 fn classify_unknown_shape_is_fatal() {
352 assert_eq!(
353 classify(&err("error decoding response body: expected value")),
354 Retryability::Fatal
355 );
356 }
357
358 #[test]
359 fn status_code_parsing() {
360 assert_eq!(
361 status_code_in("vLLM returned 503 Service Unavailable: x"),
362 Some(503)
363 );
364 assert_eq!(status_code_in("Ollama returned 429: y"), Some(429));
365 assert_eq!(status_code_in("no status here"), None);
366 }
367
368 #[test]
369 fn classify_inference_endpoint_5xx_is_retry() {
370 assert_eq!(
373 classify(&err(
374 r#"inference endpoint 500 Internal Server Error: {"error":{"message":"instance_id not found"}}"#
375 )),
376 Retryability::Retry
377 );
378 assert_eq!(
379 classify(&err("inference endpoint 503 Service Unavailable: down")),
380 Retryability::Retry
381 );
382 assert_eq!(
383 classify(&err("inference endpoint 429 Too Many Requests: slow")),
384 Retryability::Retry
385 );
386 }
387
388 #[test]
389 fn status_code_parsing_inference_endpoint_format() {
390 assert_eq!(
391 status_code_in("inference endpoint 500 Internal Server Error: body"),
392 Some(500)
393 );
394 assert_eq!(
395 status_code_in("inference endpoint 429 Too Many Requests: body"),
396 Some(429)
397 );
398 assert_eq!(
399 status_code_in("inference endpoint 400 Bad Request: body"),
400 Some(400)
401 );
402 }
403
404 #[test]
405 fn base_delay_is_exponential_and_capped() {
406 let p = RetryPolicy {
407 max_retries: 10,
408 base: Duration::from_millis(500),
409 max: Duration::from_secs(8),
410 jitter: false,
411 };
412 assert_eq!(p.base_delay_ms(1), 500);
413 assert_eq!(p.base_delay_ms(2), 1000);
414 assert_eq!(p.base_delay_ms(3), 2000);
415 assert_eq!(p.base_delay_ms(4), 4000);
416 assert_eq!(p.base_delay_ms(5), 8000);
417 assert_eq!(p.base_delay_ms(6), 8000);
419 assert_eq!(p.base_delay_ms(30), 8000);
420 }
421
422 #[test]
423 fn delay_without_jitter_is_deterministic() {
424 let p = RetryPolicy {
425 jitter: false,
426 ..RetryPolicy::default()
427 };
428 assert_eq!(p.delay_for(1), Duration::from_millis(500));
429 assert_eq!(p.delay_for(2), Duration::from_millis(1000));
430 }
431
432 #[test]
433 fn jittered_delay_stays_within_equal_jitter_band() {
434 let p = RetryPolicy {
435 max_retries: 4,
436 base: Duration::from_millis(1000),
437 max: Duration::from_secs(8),
438 jitter: true,
439 };
440 for _ in 0..200 {
442 let ms = p.delay_for(1).as_millis() as u64;
443 assert!((500..=1000).contains(&ms), "delay {ms} outside [500,1000]");
444 }
445 }
446
447 #[test]
448 fn immediate_policy_never_sleeps() {
449 let p = RetryPolicy::immediate(3);
450 assert_eq!(p.max_retries, 3);
451 assert_eq!(p.delay_for(1), Duration::ZERO);
452 assert_eq!(p.delay_for(3), Duration::ZERO);
453 }
454
455 #[tokio::test]
456 async fn with_backoff_succeeds_after_transient_failures() {
457 let calls = Cell::new(0u32);
458 let result: anyhow::Result<&str> = with_backoff(&RetryPolicy::immediate(5), || {
459 let n = calls.get() + 1;
460 calls.set(n);
461 async move {
462 if n < 3 {
463 Err(err("vLLM returned 429 Too Many Requests: slow"))
464 } else {
465 Ok("ok")
466 }
467 }
468 })
469 .await;
470 assert_eq!(result.unwrap(), "ok");
471 assert_eq!(calls.get(), 3, "should have retried twice then succeeded");
472 }
473
474 #[tokio::test]
475 async fn with_backoff_stops_on_fatal() {
476 let calls = Cell::new(0u32);
477 let result: anyhow::Result<&str> = with_backoff(&RetryPolicy::immediate(5), || {
478 calls.set(calls.get() + 1);
479 async move { Err(err("vLLM returned 400 Bad Request: nope")) }
480 })
481 .await;
482 assert!(result.is_err());
483 assert_eq!(calls.get(), 1, "fatal error must not retry");
484 }
485
486 #[tokio::test]
487 async fn with_backoff_gives_up_after_max_retries() {
488 let calls = Cell::new(0u32);
489 let result: anyhow::Result<&str> = with_backoff(&RetryPolicy::immediate(3), || {
490 calls.set(calls.get() + 1);
491 async move { Err(err("vLLM returned 503 Service Unavailable: down")) }
492 })
493 .await;
494 let e = result.unwrap_err();
495 assert!(e.to_string().contains("503"), "last error preserved: {e}");
496 assert_eq!(calls.get(), 4, "1 initial + 3 retries");
497 }
498
499 #[tokio::test]
500 async fn with_backoff_notify_fires_callback_before_each_sleep() {
501 let calls = Cell::new(0u32);
502 let notified = Cell::new(0u32);
503 let result: anyhow::Result<&str> = with_backoff_notify(
504 &RetryPolicy::immediate(3),
505 || {
506 let n = calls.get() + 1;
507 calls.set(n);
508 async move {
509 if n <= 2 {
510 Err(err("vLLM returned 503 Service Unavailable: down"))
511 } else {
512 Ok("ok")
513 }
514 }
515 },
516 |_, _| {
517 notified.set(notified.get() + 1);
518 },
519 )
520 .await;
521 assert_eq!(result.unwrap(), "ok");
522 assert_eq!(calls.get(), 3, "two retries then success");
523 assert_eq!(
524 notified.get(),
525 2,
526 "callback fired once before each retry sleep"
527 );
528 }
529
530 #[test]
531 fn for_local_inference_is_more_patient_than_default() {
532 let local = RetryPolicy::for_local_inference();
533 let default = RetryPolicy::default();
534 assert!(
535 local.max_retries > default.max_retries,
536 "local policy must allow more retries"
537 );
538 assert!(
539 local.max > default.max,
540 "local policy must have a longer backoff ceiling"
541 );
542 }
543
544 #[test]
545 fn from_env_or_starts_from_provided_base() {
546 let base = RetryPolicy {
548 max_retries: 9,
549 base: Duration::from_secs(3),
550 max: Duration::from_secs(60),
551 jitter: false,
552 };
553 let result = RetryPolicy::from_env_or(base.clone());
554 assert_eq!(result.max_retries, 9);
555 assert_eq!(result.base, Duration::from_secs(3));
556 assert_eq!(result.max, Duration::from_secs(60));
557 assert!(!result.jitter);
558 }
559}