use std::{
future::Future,
time::{Duration, Instant, SystemTime},
};
use http::HeaderMap;
use murmur_artifact::InferenceRetry;
use wasmtime_wasi_http::Error as WasiHttpError;
use crate::{agent::ProviderCounts, credential_gateway::UpstreamOutcome, spend::SpendAdmission};
pub(crate) const TRANSIENT_METADATA_KEY: &str = "transient";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RetryPolicy {
pub(crate) max_retries: u32,
initial_delay: Duration,
max_delay: Duration,
}
impl From<InferenceRetry> for RetryPolicy {
fn from(retry: InferenceRetry) -> Self {
Self {
max_retries: retry.max_retries,
initial_delay: Duration::from_millis(u64::from(retry.initial_delay_ms)),
max_delay: Duration::from_millis(u64::from(retry.max_delay_ms)),
}
}
}
impl Default for RetryPolicy {
fn default() -> Self {
InferenceRetry::default().into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AttemptFailure {
Dispatch,
Status,
StopReasonError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RetryReason {
Status,
Connection,
Driver,
}
impl RetryReason {
pub(crate) fn of(outcome: Option<UpstreamOutcome>) -> Self {
match outcome {
Some(UpstreamOutcome::Response { status, .. }) if !(200..300).contains(&status) => {
Self::Status
}
Some(UpstreamOutcome::ConnectionFailed { .. }) => Self::Connection,
_ => Self::Driver,
}
}
pub(crate) fn wire_name(self) -> &'static str {
match self {
Self::Status => "status",
Self::Connection => "connection",
Self::Driver => "driver",
}
}
pub(crate) fn provider_status(self, outcome: Option<UpstreamOutcome>) -> Option<u16> {
outcome
.and_then(UpstreamOutcome::status)
.filter(|_| self == Self::Status)
}
pub(crate) fn releases_admission(self) -> bool {
matches!(self, Self::Status | Self::Connection)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RetryStop {
MaxRetries,
RetryAfter,
Deadline,
}
impl RetryStop {
pub(crate) fn wire_name(self) -> &'static str {
match self {
Self::MaxRetries => "max_retries",
Self::RetryAfter => "retry_after",
Self::Deadline => "deadline",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RetryDecision {
Retry { wait: Duration },
Stop(RetryStop),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct RetryNote {
pub(crate) attempts: Option<u32>,
pub(crate) stopped: Option<RetryStop>,
}
impl RetryNote {
pub(crate) fn new(attempts: u32, stopped: Option<RetryStop>) -> Self {
Self {
attempts: (attempts > 1).then_some(attempts),
stopped,
}
}
pub(crate) fn describe(self) -> Option<String> {
if self.attempts.is_none() && self.stopped.is_none() {
return None;
}
let attempts = match self.attempts.unwrap_or(1) {
1 => "1 attempt".to_string(),
n => format!("{n} attempts"),
};
Some(match self.stopped {
Some(stop) => format!("{attempts}; retry stopped: {}", stop.wire_name()),
None => attempts,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InferenceRetryRecord {
pub(crate) origin: Option<String>,
pub(crate) attempt: u32,
pub(crate) max_retries: u32,
pub(crate) reason: RetryReason,
pub(crate) provider_status: Option<u16>,
pub(crate) error: String,
pub(crate) delay_ms: u64,
pub(crate) retry_after_ms: Option<u64>,
pub(crate) charged_tokens: u64,
}
pub(crate) fn charge_retried_attempt(
admission: SpendAdmission,
reason: RetryReason,
counts: Option<ProviderCounts>,
) -> u64 {
if reason.releases_admission() {
admission.release();
return 0;
}
match counts {
Some(counts) => {
admission.settle(counts.request, counts.completion);
counts.request.saturating_add(counts.completion)
}
None => admission.charge_input(),
}
}
pub(crate) fn millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
impl RetryPolicy {
pub(crate) fn backoff(&self, retry: u32, draw: u64) -> Duration {
let initial = millis(self.initial_delay);
let max = millis(self.max_delay);
let scaled = 1u64
.checked_shl(retry)
.and_then(|factor| initial.checked_mul(factor))
.unwrap_or(u64::MAX);
let d = scaled.min(max);
let low = d / 2;
let span = d - low;
let jitter = match span.checked_add(1) {
Some(width) => draw % width,
None => draw,
};
Duration::from_millis(low + jitter)
}
pub(crate) fn decide(
&self,
retries_made: u32,
retry_after: Option<Duration>,
draw: u64,
deadline: Option<Instant>,
now: Instant,
) -> RetryDecision {
if retries_made >= self.max_retries {
return RetryDecision::Stop(RetryStop::MaxRetries);
}
if retry_after.is_some_and(|wait| wait > self.max_delay) {
return RetryDecision::Stop(RetryStop::RetryAfter);
}
let wait = retry_after.unwrap_or_else(|| self.backoff(retries_made, draw));
if let Some(deadline) = deadline {
if now.checked_add(wait).is_none_or(|end| end >= deadline) {
return RetryDecision::Stop(RetryStop::Deadline);
}
}
RetryDecision::Retry { wait }
}
}
pub(crate) fn random_draw() -> u64 {
let mut bytes = [0u8; 8];
match getrandom::fill(&mut bytes) {
Ok(()) => u64::from_le_bytes(bytes),
Err(_) => 0,
}
}
pub(crate) fn retry_after(headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
let header = |name: &str| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
};
if let Some(ms) = header("retry-after-ms").and_then(|value| value.parse::<u64>().ok()) {
return Some(Duration::from_millis(ms));
}
let value = header("retry-after")?;
if let Ok(secs) = value.parse::<u64>() {
return Some(Duration::from_secs(secs));
}
let at = chrono::DateTime::parse_from_rfc2822(value).ok()?;
let at = SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(u64::try_from(at.timestamp()).ok()?))?;
Some(at.duration_since(now).unwrap_or(Duration::ZERO))
}
pub(crate) fn transient_metadata(metadata: &[(String, String)]) -> Option<bool> {
let (_, value) = metadata
.iter()
.find(|(key, _)| key == TRANSIENT_METADATA_KEY)?;
match value.as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
pub(crate) fn retryable_status(status: u16) -> bool {
matches!(status, 408 | 429) || ((500..600).contains(&status) && !matches!(status, 501 | 505))
}
pub(crate) fn transient_connection_error(error: &WasiHttpError) -> bool {
match error {
WasiHttpError::ConnectionTerminated
| WasiHttpError::ConnectionTimeout
| WasiHttpError::ConnectionReadTimeout
| WasiHttpError::ConnectionWriteTimeout
| WasiHttpError::ConnectionLimitReached
| WasiHttpError::DnsTimeout
| WasiHttpError::DestinationUnavailable
| WasiHttpError::HttpResponseIncomplete => true,
WasiHttpError::Connect(io) => transient_io(io),
WasiHttpError::Hyper(hyper) => {
hyper.is_incomplete_message()
|| hyper.is_closed()
|| hyper.is_canceled()
|| hyper.is_timeout()
|| std::error::Error::source(hyper)
.and_then(|source| source.downcast_ref::<std::io::Error>())
.is_some_and(transient_io)
}
_ => false,
}
}
fn transient_io(error: &std::io::Error) -> bool {
use std::io::ErrorKind;
matches!(
error.kind(),
ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::TimedOut
| ErrorKind::BrokenPipe
)
}
pub(crate) fn classify(
failure: AttemptFailure,
transient: Option<bool>,
credential_rejected: bool,
outcome: Option<UpstreamOutcome>,
) -> bool {
if credential_rejected {
return false;
}
let transient = match failure {
AttemptFailure::Dispatch => None,
AttemptFailure::Status | AttemptFailure::StopReasonError => transient,
};
if let Some(transient) = transient {
return transient;
}
match outcome {
Some(UpstreamOutcome::ConnectionFailed { transient }) => transient,
Some(UpstreamOutcome::Response { status, .. }) => retryable_status(status),
None => false,
}
}
pub(crate) async fn wait(wait: Duration, cancel: Option<impl Future<Output = ()>>) -> bool {
match cancel {
Some(cancel) => tokio::select! {
biased;
() = cancel => false,
() = tokio::time::sleep(wait) => true,
},
None => {
tokio::time::sleep(wait).await;
true
}
}
}
#[cfg(test)]
mod tests {
use http::HeaderValue;
use super::*;
fn policy_with(max_retries: u32, initial_delay_ms: u32, max_delay_ms: u32) -> RetryPolicy {
InferenceRetry {
max_retries,
initial_delay_ms,
max_delay_ms,
}
.into()
}
#[test]
fn inference_retry_charge_reads_the_estimate_only_to_charge_it() {
let meter = std::sync::Arc::new(crate::spend::SpendMeter::new(None, None));
let (release, released) = std::sync::mpsc::channel::<()>();
let estimate = crate::agent::RequestEstimate::counting_with(String::new(), move |_| {
let _ = released.recv();
120
});
for reason in [RetryReason::Status, RetryReason::Connection] {
let admission = meter.admit_request(&estimate, 500).unwrap();
assert_eq!(charge_retried_attempt(admission, reason, None), 0);
}
assert!(!meter.has_open_admission());
assert_eq!(meter.used(), 0);
let admission = meter.admit_request(&estimate, 500).unwrap();
release.send(()).unwrap();
assert_eq!(
charge_retried_attempt(admission, RetryReason::Driver, None),
120
);
assert_eq!(meter.used(), 120);
}
#[test]
fn inference_retry_note_describes_the_attempts_and_the_stop() {
assert_eq!(RetryNote::new(1, None).describe(), None);
assert_eq!(
RetryNote::new(3, Some(RetryStop::MaxRetries))
.describe()
.as_deref(),
Some("3 attempts; retry stopped: max_retries")
);
assert_eq!(
RetryNote::new(1, Some(RetryStop::Deadline))
.describe()
.as_deref(),
Some("1 attempt; retry stopped: deadline")
);
assert_eq!(
RetryNote::new(2, None).describe().as_deref(),
Some("2 attempts")
);
}
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
map
}
fn status(status: u16) -> Option<UpstreamOutcome> {
Some(UpstreamOutcome::Response {
status,
retry_after: None,
})
}
#[test]
fn inference_retry_after_ms_wins_over_retry_after() {
let now = SystemTime::now();
assert_eq!(
retry_after(
&headers(&[("retry-after-ms", "1500"), ("retry-after", "9")]),
now
),
Some(Duration::from_millis(1500))
);
}
#[test]
fn inference_retry_after_delta_seconds() {
assert_eq!(
retry_after(&headers(&[("retry-after", "3")]), SystemTime::now()),
Some(Duration::from_millis(3000))
);
}
#[test]
fn inference_retry_after_http_date_ahead_and_past() {
let now = SystemTime::now();
let ahead =
chrono::DateTime::<chrono::Utc>::from(now + Duration::from_secs(5)).to_rfc2822();
let wait = retry_after(&headers(&[("retry-after", &ahead)]), now).unwrap();
assert!(
(4_000..=6_000).contains(&wait.as_millis()),
"{ahead}: {wait:?}"
);
let past =
chrono::DateTime::<chrono::Utc>::from(now - Duration::from_secs(60)).to_rfc2822();
assert_eq!(
retry_after(&headers(&[("retry-after", &past)]), now),
Some(Duration::ZERO)
);
assert_eq!(
retry_after(
&headers(&[("retry-after", "Wed, 21 Oct 2015 07:28:00 GMT")]),
now
),
Some(Duration::ZERO)
);
}
#[test]
fn inference_retry_after_garbage_is_none() {
let now = SystemTime::now();
assert_eq!(retry_after(&headers(&[("retry-after", "soon")]), now), None);
assert_eq!(retry_after(&headers(&[("retry-after", "-3")]), now), None);
assert_eq!(retry_after(&headers(&[]), now), None);
assert_eq!(
retry_after(
&headers(&[("retry-after-ms", "x"), ("retry-after", "2")]),
now
),
Some(Duration::from_secs(2))
);
}
#[test]
fn inference_retry_backoff_is_jittered_within_half_to_full() {
let policy = policy_with(6, 10, 2_000);
assert_eq!(policy.backoff(0, 0), Duration::from_millis(5));
assert_eq!(policy.backoff(0, 5), Duration::from_millis(10));
assert_eq!(policy.backoff(0, 6), Duration::from_millis(5));
assert_eq!(policy.backoff(1, 0), Duration::from_millis(10));
assert_eq!(policy.backoff(1, 10), Duration::from_millis(20));
for draw in [0, 1, 7, 99, u64::MAX] {
let wait = policy.backoff(2, draw).as_millis();
assert!((20..=40).contains(&wait), "{wait}");
}
assert_eq!(policy.backoff(10, 1_000), Duration::from_millis(2_000));
assert_eq!(policy.backoff(63, 0), Duration::from_millis(1_000));
assert_eq!(policy.backoff(64, 0), Duration::from_millis(1_000));
assert!(policy.backoff(u32::MAX, u64::MAX) <= Duration::from_millis(2_000));
assert!(policy_with(1, 1, 1).backoff(0, 0) <= Duration::from_millis(1));
}
#[test]
fn inference_retry_decide_checks_count_then_retry_after_then_deadline() {
let policy = policy_with(2, 10, 5_000);
let now = Instant::now();
assert_eq!(
policy.decide(2, None, 0, None, now),
RetryDecision::Stop(RetryStop::MaxRetries)
);
assert_eq!(
policy.decide(2, Some(Duration::from_secs(60)), 0, None, now),
RetryDecision::Stop(RetryStop::MaxRetries)
);
assert_eq!(
policy.decide(0, Some(Duration::from_secs(6)), 0, None, now),
RetryDecision::Stop(RetryStop::RetryAfter)
);
assert_eq!(
policy.decide(0, Some(Duration::from_secs(5)), 0, None, now),
RetryDecision::Retry {
wait: Duration::from_secs(5)
}
);
let deadline = now + Duration::from_secs(5);
assert_eq!(
policy.decide(0, Some(Duration::from_secs(5)), 0, Some(deadline), now),
RetryDecision::Stop(RetryStop::Deadline),
"a wait ending at the deadline is refused"
);
assert_eq!(
policy.decide(1, None, 0, Some(deadline), now),
RetryDecision::Retry {
wait: Duration::from_millis(10)
}
);
assert_eq!(
policy.decide(0, None, 0, None, now),
RetryDecision::Retry {
wait: Duration::from_millis(5)
}
);
assert_eq!(
policy_with(0, 1, 1).decide(0, None, 0, None, now),
RetryDecision::Stop(RetryStop::MaxRetries)
);
}
#[test]
fn inference_retry_transient_metadata_takes_the_first_tuple_and_two_values() {
let md = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
assert_eq!(transient_metadata(&md(&[])), None);
assert_eq!(
transient_metadata(&md(&[("transient", "true")])),
Some(true)
);
assert_eq!(
transient_metadata(&md(&[("transient", "false")])),
Some(false)
);
assert_eq!(transient_metadata(&md(&[("transient", "TRUE")])), None);
assert_eq!(transient_metadata(&md(&[("transient", "yes")])), None);
assert_eq!(
transient_metadata(&md(&[("transient", "false"), ("transient", "true")])),
Some(false)
);
assert_eq!(
transient_metadata(&md(&[("transient", "maybe"), ("transient", "true")])),
None,
"only the first tuple counts"
);
}
#[test]
fn inference_retry_statuses() {
for retried in [408, 429, 500, 502, 503, 504, 529, 599] {
assert!(retryable_status(retried), "{retried}");
}
for not in [200, 307, 400, 401, 403, 404, 409, 413, 422, 501, 505, 600] {
assert!(!retryable_status(not), "{not}");
}
}
#[test]
fn inference_retry_classify_follows_the_rules_in_order() {
use AttemptFailure::*;
assert!(!classify(Status, Some(true), true, status(401)));
assert!(!classify(Status, Some(false), false, status(429)));
assert!(classify(StopReasonError, Some(true), false, status(200)));
assert!(classify(Status, Some(true), false, status(400)));
let dropped = Some(UpstreamOutcome::ConnectionFailed { transient: true });
let refused = Some(UpstreamOutcome::ConnectionFailed { transient: false });
assert!(classify(Status, None, false, dropped));
assert!(classify(Dispatch, None, false, dropped));
assert!(!classify(Status, None, false, refused));
assert!(classify(Status, None, false, status(503)));
assert!(!classify(Status, None, false, status(501)));
assert!(!classify(Status, None, false, status(400)));
assert!(!classify(Status, None, false, None));
assert!(!classify(StopReasonError, None, false, status(200)));
assert!(!classify(Dispatch, Some(true), false, None));
assert!(classify(Dispatch, Some(false), false, dropped));
assert!(classify(Dispatch, None, false, status(429)));
}
#[test]
fn inference_retry_connection_errors() {
use std::io::{Error, ErrorKind};
for transient in [
WasiHttpError::ConnectionTerminated,
WasiHttpError::ConnectionTimeout,
WasiHttpError::ConnectionReadTimeout,
WasiHttpError::ConnectionWriteTimeout,
WasiHttpError::ConnectionLimitReached,
WasiHttpError::DnsTimeout,
WasiHttpError::DestinationUnavailable,
WasiHttpError::HttpResponseIncomplete,
WasiHttpError::Connect(Error::from(ErrorKind::ConnectionReset)),
WasiHttpError::Connect(Error::from(ErrorKind::ConnectionAborted)),
WasiHttpError::Connect(Error::from(ErrorKind::TimedOut)),
WasiHttpError::Connect(Error::from(ErrorKind::BrokenPipe)),
] {
assert!(transient_connection_error(&transient), "{transient:?}");
}
for not in [
WasiHttpError::ConnectionRefused,
WasiHttpError::Connect(Error::from(ErrorKind::ConnectionRefused)),
WasiHttpError::DnsError {
rcode: None,
info_code: None,
},
WasiHttpError::DestinationNotFound,
WasiHttpError::Tls(Error::from(ErrorKind::InvalidData)),
WasiHttpError::TlsProtocolError,
WasiHttpError::TlsCertificateError,
WasiHttpError::HttpRequestUriInvalid,
WasiHttpError::InternalError(None),
] {
assert!(!transient_connection_error(¬), "{not:?}");
}
}
#[test]
fn inference_retry_reason_and_charging() {
assert_eq!(RetryReason::of(status(429)), RetryReason::Status);
assert_eq!(RetryReason::of(status(200)), RetryReason::Driver);
assert_eq!(RetryReason::of(None), RetryReason::Driver);
assert_eq!(
RetryReason::of(Some(UpstreamOutcome::ConnectionFailed { transient: true })),
RetryReason::Connection
);
assert_eq!(RetryReason::Status.provider_status(status(429)), Some(429));
assert_eq!(RetryReason::Driver.provider_status(status(200)), None);
assert!(RetryReason::Status.releases_admission());
assert!(RetryReason::Connection.releases_admission());
assert!(!RetryReason::Driver.releases_admission());
assert_eq!(RetryNote::new(1, None), RetryNote::default());
assert_eq!(
RetryNote::new(3, Some(RetryStop::MaxRetries)),
RetryNote {
attempts: Some(3),
stopped: Some(RetryStop::MaxRetries)
}
);
}
#[tokio::test]
async fn inference_retry_wait_is_cut_short_by_a_cancel() {
let started = Instant::now();
assert!(!wait(Duration::from_secs(60), Some(async {})).await);
assert!(started.elapsed() < Duration::from_secs(5));
assert!(wait(Duration::from_millis(5), None::<std::future::Ready<()>>).await);
assert!(wait(Duration::from_millis(5), Some(std::future::pending::<()>())).await);
}
}