1pub mod backoff;
8
9use std::time::{Duration, Instant};
10
11use crate::error::{Error, Result, TransportErrorKind, codes};
12use crate::http::HttpResponse;
13use crate::wire;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct RequestOptions {
18 pub gpu: Option<String>,
20 pub wait_for_capacity: bool,
22 pub provision_timeout: Duration,
24 pub max_oom_retries: u32,
26}
27
28impl Default for RequestOptions {
29 fn default() -> Self {
30 Self {
31 gpu: None,
32 wait_for_capacity: true,
33 provision_timeout: backoff::DEFAULT_PROVISION_TIMEOUT,
34 max_oom_retries: backoff::RESOURCE_EXHAUSTED_MAX_RETRIES,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub(crate) struct RetryPolicy {
45 pub lora_loading: bool,
47 pub model_loading_gated_on_wait: bool,
49 pub retry_gateway_timeout: bool,
51 pub retry_connect: bool,
53 pub retry_midflight_transport: bool,
55 pub oom_gated_on_wait: bool,
57 pub estimate_unroutable: bool,
59}
60
61impl RetryPolicy {
62 pub(crate) const INFERENCE: Self = Self {
64 lora_loading: false,
65 model_loading_gated_on_wait: false,
66 retry_gateway_timeout: true,
67 retry_connect: true,
68 retry_midflight_transport: true,
69 oom_gated_on_wait: false,
70 estimate_unroutable: false,
71 };
72
73 pub(crate) const ENCODE: Self = Self {
75 lora_loading: true,
76 ..Self::INFERENCE
77 };
78
79 pub(crate) const GENERATE: Self = Self {
81 lora_loading: false,
82 model_loading_gated_on_wait: true,
83 retry_gateway_timeout: false,
84 retry_connect: true,
85 retry_midflight_transport: false,
86 oom_gated_on_wait: false,
87 estimate_unroutable: false,
88 };
89
90 pub(crate) const STREAM: Self = Self {
92 lora_loading: false,
93 model_loading_gated_on_wait: false,
94 retry_gateway_timeout: false,
95 retry_connect: true,
96 retry_midflight_transport: false,
97 oom_gated_on_wait: true,
98 estimate_unroutable: false,
99 };
100
101 pub(crate) const NONE: Self = Self {
106 lora_loading: false,
107 model_loading_gated_on_wait: false,
108 retry_gateway_timeout: false,
109 retry_connect: false,
110 retry_midflight_transport: false,
111 oom_gated_on_wait: false,
112 estimate_unroutable: false,
113 };
114
115 pub(crate) const ESTIMATE: Self = Self {
118 estimate_unroutable: true,
119 ..Self::NONE
120 };
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub(crate) enum Decision {
126 Accept,
128 Retry(Duration),
130}
131
132#[derive(Debug)]
134pub(crate) struct RetryState {
135 policy: RetryPolicy,
136 wait_for_capacity: bool,
137 max_oom_retries: u32,
138 budget: Duration,
139 start: Instant,
140 gpu: Option<String>,
141 model: Option<String>,
142 retries: u32,
143 oom_retries: u32,
144 lora_retries: u32,
145}
146
147impl RetryState {
148 pub(crate) fn new(policy: RetryPolicy, options: &RequestOptions, model: Option<&str>) -> Self {
149 Self {
150 policy,
151 wait_for_capacity: options.wait_for_capacity,
152 max_oom_retries: options.max_oom_retries,
153 budget: options.provision_timeout,
154 start: Instant::now(),
155 gpu: options.gpu.clone(),
156 model: model.map(str::to_string),
157 retries: 0,
158 oom_retries: 0,
159 lora_retries: 0,
160 }
161 }
162
163 pub(crate) fn retries(&self) -> u32 {
165 self.retries
166 }
167
168 fn elapsed(&self) -> Duration {
169 self.start.elapsed()
170 }
171
172 fn remaining(&self) -> Duration {
173 self.budget.saturating_sub(self.elapsed())
174 }
175
176 pub(crate) fn attempt_timeout(&self, client_timeout: Duration) -> Result<Duration> {
178 let remaining = self.remaining();
179 if remaining.is_zero() {
180 return Err(Error::Provisioning {
181 message: format!(
182 "Provision timeout ({:.1}s) exceeded before request could be sent",
183 self.budget.as_secs_f64()
184 ),
185 gpu: self.gpu.clone(),
186 retry_after: None,
187 });
188 }
189 Ok(client_timeout.min(remaining))
190 }
191
192 pub(crate) fn on_transport_error(
194 &mut self,
195 error: &reqwest::Error,
196 base_url: &str,
197 ) -> Result<Duration> {
198 let kind = classify_transport_error(error);
199 let retryable = self.wait_for_capacity
200 && match kind {
201 TransportErrorKind::Connect => {
202 self.policy.retry_connect && is_transient_connect_error(error)
203 }
204 TransportErrorKind::Timeout | TransportErrorKind::MidFlight => {
205 self.policy.retry_midflight_transport
206 }
207 };
208
209 if retryable && let Some(delay) = backoff::transport_delay(self.elapsed(), self.budget) {
210 tracing::debug!(
211 "{} retrying in {:.1}s (elapsed: {:.1}s, timeout: {:.1}s): {error}",
212 transport_label(kind),
213 delay.as_secs_f64(),
214 self.elapsed().as_secs_f64(),
215 self.budget.as_secs_f64(),
216 );
217 self.retries += 1;
218 return Ok(delay);
219 }
220
221 let message = match kind {
222 TransportErrorKind::Connect => format!("Failed to connect to {base_url}: {error}"),
223 TransportErrorKind::Timeout => format!("Request timed out: {error}"),
224 TransportErrorKind::MidFlight => format!(
225 "Connection lost mid-request; the peer closed the connection before sending a \
226 complete response: {error}"
227 ),
228 };
229 Err(Error::Connection {
230 message,
231 kind,
232 source: None,
233 })
234 }
235
236 pub(crate) fn on_response(&mut self, response: &HttpResponse) -> Result<Decision> {
241 wire::check_model_load_failed(response, self.model.as_deref(), self.retries)?;
244 if self.policy.estimate_unroutable {
245 wire::check_estimate_unroutable(response, self.retries)?;
246 }
247
248 if response.status == 503
249 && let Some(delay) = self.on_service_unavailable(response)?
250 {
251 self.retries += 1;
252 return Ok(Decision::Retry(delay));
253 }
254
255 if response.status == 504 {
256 if let Some(delay) = self.on_gateway_timeout(response) {
257 self.retries += 1;
258 return Ok(Decision::Retry(delay));
259 }
260 if !self.policy.retry_gateway_timeout {
261 return Err(Error::Server {
262 message: "Gateway timed out (504) after the request was published to the queue; \
263 a worker may already be generating. Not retried because generation is \
264 non-idempotent (retrying could double-bill)."
265 .to_string(),
266 code: wire::error_code(response),
267 status: 504,
268 request: crate::http::metadata::parse(&response.headers, None, self.retries).map(Box::new),
269 });
270 }
271 }
272
273 if response.status >= 400 {
274 return Err(wire::handle_error(
275 response,
276 self.model.as_deref(),
277 self.retries,
278 ));
279 }
280 Ok(Decision::Accept)
281 }
282
283 fn on_service_unavailable(&mut self, response: &HttpResponse) -> Result<Option<Duration>> {
286 let Some(code) = wire::error_code(response) else {
287 return Ok(None);
288 };
289 let hint = backoff::retry_after(&response.headers);
290
291 match code.as_str() {
292 codes::PROVISIONING => self.provisioning_delay(hint).map(Some),
293 codes::LORA_LOADING if self.policy.lora_loading => self.lora_delay(hint).map(Some),
294 codes::MODEL_LOADING => {
295 if self.policy.model_loading_gated_on_wait && !self.wait_for_capacity {
296 return Ok(None);
297 }
298 self.model_loading_delay(hint).map(Some)
299 }
300 codes::RESOURCE_EXHAUSTED => {
301 if self.policy.oom_gated_on_wait && !self.wait_for_capacity {
302 return Err(self.resource_exhausted(response));
303 }
304 self.oom_delay(response, hint).map(Some)
305 }
306 _ => Ok(None),
307 }
308 }
309
310 fn provisioning_delay(&self, hint: Option<Duration>) -> Result<Duration> {
311 let gpu_label = self.gpu.as_deref().unwrap_or("default");
312 if !self.wait_for_capacity {
313 return Err(Error::Provisioning {
314 message: format!(
315 "No capacity available for GPU '{gpu_label}'. Server is provisioning."
316 ),
317 gpu: self.gpu.clone(),
318 retry_after: hint,
319 });
320 }
321 let remaining = self.remaining();
322 if remaining.is_zero() {
323 return Err(Error::Provisioning {
324 message: format!(
325 "Provisioning timeout after {:.1}s waiting for GPU '{gpu_label}'",
326 self.elapsed().as_secs_f64()
327 ),
328 gpu: self.gpu.clone(),
329 retry_after: hint,
330 });
331 }
332 Ok(match hint {
334 Some(hint) => hint.min(remaining),
335 None => backoff::apply_jitter(backoff::DEFAULT_RETRY_DELAY.min(remaining)),
336 })
337 }
338
339 fn model_loading_delay(&self, hint: Option<Duration>) -> Result<Duration> {
340 let remaining = self.remaining();
341 if remaining.is_zero() {
342 return Err(Error::ModelLoading {
343 message: format!(
344 "Model loading timeout after {:.1}s for '{}'",
345 self.elapsed().as_secs_f64(),
346 self.model.as_deref().unwrap_or("model")
347 ),
348 model: self.model.clone(),
349 });
350 }
351 Ok(backoff::retry_after_or(hint, backoff::MODEL_LOADING_DELAY).min(remaining))
352 }
353
354 fn lora_delay(&mut self, hint: Option<Duration>) -> Result<Duration> {
355 self.lora_retries += 1;
356 let remaining = self.remaining();
357 if self.lora_retries > backoff::LORA_LOADING_MAX_RETRIES || remaining.is_zero() {
358 return Err(Error::LoraLoading {
359 message: format!(
360 "LoRA loading timeout after {} retries",
361 self.lora_retries - 1
362 ),
363 lora: None,
364 model: self.model.clone(),
365 });
366 }
367 Ok(backoff::retry_after_or(hint, backoff::LORA_LOADING_DELAY).min(remaining))
370 }
371
372 fn oom_delay(&mut self, response: &HttpResponse, hint: Option<Duration>) -> Result<Duration> {
373 let remaining = self.remaining();
374 if self.oom_retries >= self.max_oom_retries || remaining.is_zero() {
375 return Err(self.resource_exhausted(response));
376 }
377 let delay = backoff::oom_backoff(hint, self.oom_retries);
378 if delay >= remaining {
381 return Err(self.resource_exhausted(response));
382 }
383 if self.oom_retries == 0 {
384 tracing::warn!(
385 "Server resource exhausted, retrying in {:.1}s (attempt 1/{}, elapsed: {:.1}s, timeout: {:.1}s)",
386 delay.as_secs_f64(),
387 self.max_oom_retries,
388 self.elapsed().as_secs_f64(),
389 self.budget.as_secs_f64(),
390 );
391 } else {
392 tracing::info!(
393 "Server resource exhausted, retrying in {:.1}s (attempt {}/{})",
394 delay.as_secs_f64(),
395 self.oom_retries + 1,
396 self.max_oom_retries,
397 );
398 }
399 self.oom_retries += 1;
400 Ok(delay)
401 }
402
403 fn resource_exhausted(&self, response: &HttpResponse) -> Error {
404 Error::ResourceExhausted {
405 message: format!(
406 "Server resource exhausted after {} retry attempt(s) for model '{}'",
407 self.oom_retries,
408 self.model.as_deref().unwrap_or("unknown")
409 ),
410 model: self.model.clone(),
411 retries: self.oom_retries,
412 request: crate::http::metadata::parse(&response.headers, None, self.retries)
413 .map(Box::new),
414 }
415 }
416
417 fn on_gateway_timeout(&self, response: &HttpResponse) -> Option<Duration> {
418 if !self.policy.retry_gateway_timeout || !self.wait_for_capacity {
419 return None;
420 }
421 let remaining = self.remaining();
422 if remaining.is_zero() {
423 return None;
424 }
425 let hint = backoff::retry_after(&response.headers);
426 let delay = backoff::retry_after_or(hint, backoff::MODEL_LOADING_DELAY).min(remaining);
427 tracing::info!(
428 "Gateway timeout (504), retrying in {:.1}s (elapsed: {:.1}s, timeout: {:.1}s)",
429 delay.as_secs_f64(),
430 self.elapsed().as_secs_f64(),
431 self.budget.as_secs_f64(),
432 );
433 Some(delay)
434 }
435}
436
437fn transport_label(kind: TransportErrorKind) -> &'static str {
438 match kind {
439 TransportErrorKind::Connect => "Connect error,",
440 TransportErrorKind::Timeout => "Request timeout,",
441 TransportErrorKind::MidFlight => "Transient transport error,",
442 }
443}
444
445pub(crate) fn classify_transport_error(error: &reqwest::Error) -> TransportErrorKind {
446 if error.is_timeout() {
447 TransportErrorKind::Timeout
448 } else if error.is_connect() {
449 TransportErrorKind::Connect
450 } else {
451 TransportErrorKind::MidFlight
452 }
453}
454
455const TRANSIENT_ERRNOS: &[i32] = &[
457 libc_errno::ECONNREFUSED,
458 libc_errno::ECONNRESET,
459 libc_errno::ETIMEDOUT,
460 libc_errno::EHOSTUNREACH,
461 libc_errno::ENETUNREACH,
462 libc_errno::ENETDOWN,
463 libc_errno::EHOSTDOWN,
464];
465
466pub(crate) fn is_transient_connect_error(error: &reqwest::Error) -> bool {
474 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(error);
475 while let Some(current) = source {
476 if let Some(io_error) = current.downcast_ref::<std::io::Error>() {
477 if let Some(errno) = io_error.raw_os_error() {
478 return TRANSIENT_ERRNOS.contains(&errno);
479 }
480 if io_error.kind() == std::io::ErrorKind::InvalidData {
481 return false;
482 }
483 }
484 source = current.source();
485 }
486 true
487}
488
489#[allow(non_snake_case)]
491mod libc_errno {
492 pub const ECONNREFUSED: i32 = 111;
493 pub const ECONNRESET: i32 = 104;
494 pub const ETIMEDOUT: i32 = 110;
495 pub const EHOSTUNREACH: i32 = 113;
496 pub const ENETUNREACH: i32 = 101;
497 pub const ENETDOWN: i32 = 100;
498 pub const EHOSTDOWN: i32 = 112;
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use bytes::Bytes;
505 use reqwest::header::HeaderMap;
506
507 fn response(status: u16, code: Option<&str>, retry_after: Option<&str>) -> HttpResponse {
508 let mut headers = HeaderMap::new();
509 headers.insert(
510 reqwest::header::CONTENT_TYPE,
511 "application/json".parse().unwrap(),
512 );
513 if let Some(value) = retry_after {
514 headers.insert(reqwest::header::RETRY_AFTER, value.parse().unwrap());
515 }
516 let body = match code {
517 Some(code) => format!(r#"{{"error": {{"code": "{code}", "message": "m"}}}}"#),
518 None => "{}".to_string(),
519 };
520 HttpResponse {
521 status,
522 headers,
523 body: Bytes::from(body),
524 }
525 }
526
527 fn state(policy: RetryPolicy, options: RequestOptions) -> RetryState {
528 RetryState::new(policy, &options, Some("bge-m3"))
529 }
530
531 #[test]
532 fn success_is_accepted() {
533 let mut state = state(RetryPolicy::ENCODE, RequestOptions::default());
534 assert_eq!(
535 state.on_response(&response(200, None, None)).unwrap(),
536 Decision::Accept
537 );
538 assert_eq!(state.retries(), 0);
539 }
540
541 #[test]
542 fn provisioning_retries_and_honours_the_hint() {
543 let mut state = state(RetryPolicy::ENCODE, RequestOptions::default());
544 let decision = state
545 .on_response(&response(503, Some(codes::PROVISIONING), Some("7")))
546 .unwrap();
547 assert_eq!(decision, Decision::Retry(Duration::from_secs(7)));
548 assert_eq!(state.retries(), 1);
549 }
550
551 #[test]
552 fn provisioning_fails_fast_without_wait_for_capacity() {
553 let mut state = state(
554 RetryPolicy::ENCODE,
555 RequestOptions {
556 wait_for_capacity: false,
557 ..Default::default()
558 },
559 );
560 let err = state
561 .on_response(&response(503, Some(codes::PROVISIONING), Some("5")))
562 .unwrap_err();
563 assert!(matches!(err, Error::Provisioning { .. }));
564 assert_eq!(err.retry_after(), Some(Duration::from_secs(5)));
565 }
566
567 #[test]
568 fn lora_loading_only_applies_to_encode() {
569 let mut encode = state(RetryPolicy::ENCODE, RequestOptions::default());
570 assert_eq!(
571 encode
572 .on_response(&response(503, Some(codes::LORA_LOADING), None))
573 .unwrap(),
574 Decision::Retry(backoff::LORA_LOADING_DELAY)
575 );
576
577 let mut score = state(RetryPolicy::INFERENCE, RequestOptions::default());
578 let err = score
579 .on_response(&response(503, Some(codes::LORA_LOADING), None))
580 .unwrap_err();
581 assert!(matches!(err, Error::Server { status: 503, .. }));
582 }
583
584 #[test]
585 fn lora_loading_gives_up_after_ten_retries() {
586 let mut state = state(RetryPolicy::ENCODE, RequestOptions::default());
587 for _ in 0..backoff::LORA_LOADING_MAX_RETRIES {
588 state
589 .on_response(&response(503, Some(codes::LORA_LOADING), None))
590 .unwrap();
591 }
592 let err = state
593 .on_response(&response(503, Some(codes::LORA_LOADING), None))
594 .unwrap_err();
595 match err {
596 Error::LoraLoading { message, .. } => {
597 assert!(message.contains("after 10 retries"), "{message}");
598 }
599 other => panic!("unexpected: {other:?}"),
600 }
601 }
602
603 #[test]
604 fn model_loading_gating_differs_between_generate_and_encode() {
605 let fail_fast = RequestOptions {
606 wait_for_capacity: false,
607 ..Default::default()
608 };
609
610 let mut encode = state(RetryPolicy::ENCODE, fail_fast.clone());
611 assert_eq!(
612 encode
613 .on_response(&response(503, Some(codes::MODEL_LOADING), None))
614 .unwrap(),
615 Decision::Retry(backoff::MODEL_LOADING_DELAY),
616 );
617
618 let mut generate = state(RetryPolicy::GENERATE, fail_fast);
619 let err = generate
620 .on_response(&response(503, Some(codes::MODEL_LOADING), None))
621 .unwrap_err();
622 assert!(matches!(err, Error::Server { status: 503, .. }));
623 }
624
625 #[test]
626 fn oom_retries_are_capped_and_then_surface_the_root_cause() {
627 let mut state = state(RetryPolicy::ENCODE, RequestOptions::default());
628 for attempt in 0..backoff::RESOURCE_EXHAUSTED_MAX_RETRIES {
629 let decision = state
630 .on_response(&response(503, Some(codes::RESOURCE_EXHAUSTED), None))
631 .unwrap();
632 let Decision::Retry(delay) = decision else {
633 panic!("expected a retry on attempt {attempt}")
634 };
635 assert!(delay <= backoff::RESOURCE_EXHAUSTED_MAX_DELAY);
636 }
637 let err = state
638 .on_response(&response(503, Some(codes::RESOURCE_EXHAUSTED), None))
639 .unwrap_err();
640 match err {
641 Error::ResourceExhausted {
642 retries, message, ..
643 } => {
644 assert_eq!(retries, 3);
645 assert!(message.contains("bge-m3"), "{message}");
646 }
647 other => panic!("unexpected: {other:?}"),
648 }
649 }
650
651 #[test]
652 fn oom_is_disabled_by_zero_max_retries() {
653 let mut state = state(
654 RetryPolicy::ENCODE,
655 RequestOptions {
656 max_oom_retries: 0,
657 ..Default::default()
658 },
659 );
660 let err = state
661 .on_response(&response(503, Some(codes::RESOURCE_EXHAUSTED), None))
662 .unwrap_err();
663 assert!(matches!(err, Error::ResourceExhausted { retries: 0, .. }));
664 }
665
666 #[test]
667 fn oom_is_gated_on_wait_for_capacity_only_on_stream_paths() {
668 let fail_fast = RequestOptions {
669 wait_for_capacity: false,
670 ..Default::default()
671 };
672
673 let mut buffered = state(RetryPolicy::ENCODE, fail_fast.clone());
674 assert!(matches!(
675 buffered.on_response(&response(503, Some(codes::RESOURCE_EXHAUSTED), None)),
676 Ok(Decision::Retry(_))
677 ));
678
679 let mut streaming = state(RetryPolicy::STREAM, fail_fast);
680 assert!(matches!(
681 streaming.on_response(&response(503, Some(codes::RESOURCE_EXHAUSTED), None)),
682 Err(Error::ResourceExhausted { .. })
683 ));
684 }
685
686 #[test]
687 fn gateway_timeout_is_replayed_only_for_idempotent_endpoints() {
688 let mut encode = state(RetryPolicy::ENCODE, RequestOptions::default());
689 assert_eq!(
690 encode.on_response(&response(504, None, Some("3"))).unwrap(),
691 Decision::Retry(Duration::from_secs(3))
692 );
693
694 let mut generate = state(RetryPolicy::GENERATE, RequestOptions::default());
695 let err = generate
696 .on_response(&response(504, None, None))
697 .unwrap_err();
698 match err {
699 Error::Server {
700 message, status, ..
701 } => {
702 assert_eq!(status, 504);
703 assert!(message.contains("double-bill"), "{message}");
704 }
705 other => panic!("unexpected: {other:?}"),
706 }
707 }
708
709 #[test]
710 fn gateway_timeout_is_terminal_without_wait_for_capacity() {
711 let mut encode = state(
712 RetryPolicy::ENCODE,
713 RequestOptions {
714 wait_for_capacity: false,
715 ..Default::default()
716 },
717 );
718 let err = encode.on_response(&response(504, None, None)).unwrap_err();
719 assert!(matches!(err, Error::Server { status: 504, .. }));
720 }
721
722 #[test]
723 fn model_load_failure_short_circuits_before_the_retry_budget() {
724 let mut headers = HeaderMap::new();
725 headers.insert(
726 reqwest::header::CONTENT_TYPE,
727 "application/json".parse().unwrap(),
728 );
729 let failed = HttpResponse {
730 status: 502,
731 headers,
732 body: Bytes::from(
733 r#"{"error": {"code": "MODEL_LOAD_FAILED", "message": "gated", "error_class": "GATED"}}"#,
734 ),
735 };
736 let mut state = state(RetryPolicy::ENCODE, RequestOptions::default());
737 assert!(matches!(
738 state.on_response(&failed),
739 Err(Error::ModelLoadFailed { .. })
740 ));
741 assert_eq!(state.retries(), 0);
742 }
743
744 #[test]
745 fn exhausted_budget_refuses_to_send() {
746 let state = state(
747 RetryPolicy::ENCODE,
748 RequestOptions {
749 provision_timeout: Duration::ZERO,
750 ..Default::default()
751 },
752 );
753 let err = state.attempt_timeout(Duration::from_secs(30)).unwrap_err();
754 match err {
755 Error::Provisioning { message, .. } => {
756 assert!(message.contains("before request could be sent"));
757 }
758 other => panic!("unexpected: {other:?}"),
759 }
760 }
761
762 #[test]
763 fn attempt_timeout_is_clamped_to_the_remaining_budget() {
764 let state = state(
765 RetryPolicy::ENCODE,
766 RequestOptions {
767 provision_timeout: Duration::from_secs(2),
768 ..Default::default()
769 },
770 );
771 assert!(state.attempt_timeout(Duration::from_secs(30)).unwrap() <= Duration::from_secs(2));
772 assert_eq!(
773 state.attempt_timeout(Duration::from_millis(500)).unwrap(),
774 Duration::from_millis(500)
775 );
776 }
777}