///|
/// Retry policy for `RetryTransport`.
///
/// Retrying is never implicit: nothing is retried until a `RetryTransport`
/// is built with `enabled: true`, so a plain client keeps the "one call, one
/// request" behaviour.
pub(all) struct RetryPolicy {
/// Whether to retry at all. `false` makes the transport a pass-through.
enabled : Bool
/// Total attempts per request, including the first one. Minimum 1.
max_attempts : Int32
/// Delay before the first retry, in milliseconds.
initial_delay_ms : Int32
/// Ceiling for the delay, in milliseconds.
max_delay_ms : Int32
/// Multiplier applied to the delay after each attempt.
backoff_factor : Double
} derive(Eq, Debug)
///|
/// Builds a retry policy. `enabled` defaults to `false`, so the caller has
/// to opt in to the retry behaviour.
pub fn RetryPolicy::new(
enabled~ : Bool = false,
max_attempts~ : Int32 = 3,
initial_delay_ms~ : Int32 = 200,
max_delay_ms~ : Int32 = 5000,
backoff_factor~ : Double = 2.0,
) -> RetryPolicy {
{
enabled,
max_attempts: if max_attempts < 1 {
1
} else {
max_attempts
},
initial_delay_ms: if initial_delay_ms < 0 {
0
} else {
initial_delay_ms
},
max_delay_ms: if max_delay_ms < 0 {
0
} else {
max_delay_ms
},
backoff_factor: if backoff_factor < 1.0 {
1.0
} else {
backoff_factor
},
}
}
///|
/// The delay to wait before attempt `attempt` (0-based) of the backoff
/// sequence, capped at `max_delay_ms`.
pub fn RetryPolicy::delay_ms(self : RetryPolicy, attempt : Int32) -> Int32 {
let mut delay = self.initial_delay_ms.to_double()
let mut index = 0
while index < attempt {
delay = delay * self.backoff_factor
index = index + 1
}
let capped = if delay > self.max_delay_ms.to_double() {
self.max_delay_ms.to_double()
} else {
delay
}
capped.to_int()
}
///|
/// A transport that re-sends requests the server answered with 429 or 5xx.
///
/// It waits between attempts only on the caller's behalf: the library never
/// sleeps, because blocking inside a transport is exactly the implicit
/// behaviour the retry policy is meant to avoid. Instead each retry is
/// announced as `on_retry(attempt, delay_ms, error)` and the delay is
/// stamped on the request as `x-chroma-retry-delay-ms`, the same contract
/// `TimeoutTransport` uses for deadlines. A transport that cannot wait and
/// has no hook still sees the retried requests; it just sends them back to
/// back.
pub(all) struct RetryTransport[T] {
inner : T
policy : RetryPolicy
on_retry : ((Int32, Int32, ChromaError) -> Unit)?
}
///|
/// The header carrying the delay a real transport should wait before
/// re-sending.
pub const RETRY_DELAY_HEADER : String = "x-chroma-retry-delay-ms"
///|
/// Wraps `inner` with `policy`.
pub fn[T] RetryTransport::new(
inner : T,
policy~ : RetryPolicy = RetryPolicy::new(),
) -> RetryTransport[T] {
{ inner, policy, on_retry: None, }
}
///|
/// Returns a copy that reports every retry before it is attempted.
///
/// The callback is where a real transport blocks, logs or reports metrics;
/// a browser build could sleep with `setTimeout` there.
pub fn[T] RetryTransport::on_retry(
self : RetryTransport[T],
handler : (Int32, Int32, ChromaError) -> Unit,
) -> RetryTransport[T] {
{ ..self, on_retry: Some(handler), }
}
///|
/// Returns a copy with a different policy.
pub fn[T] RetryTransport::with_policy(
self : RetryTransport[T],
policy : RetryPolicy,
) -> RetryTransport[T] {
{ ..self, policy, }
}
///|
/// Sends the request, retrying while the server reports a transient
/// failure and attempts remain.
pub impl[T : Transport] Transport for RetryTransport[T] with fn send(
self,
request,
) {
if !self.policy.enabled {
return self.inner.send(request)
}
let mut attempt = 0
let mut last : Response? = None
while attempt < self.policy.max_attempts {
let response = self.inner.send(request)
// `last` keeps the final response so the loop can report it on the
// last attempt without duplicating the error-building code.
last = Some(response)
if !is_transient(response) {
return response
}
attempt = attempt + 1
if attempt >= self.policy.max_attempts {
break
}
let delay = self.policy.delay_ms(attempt - 1)
match self.on_retry {
Some(handler) =>
handler(attempt, delay, api_error(response.status, response.body))
None => ()
}
// The header is recomputed per attempt rather than accumulated, so a
// transport always sees the delay of the retry it is about to make.
request.headers[RETRY_DELAY_HEADER] = delay.to_string()
}
match last {
Some(response) => raise api_error(response.status, response.body)
// `max_attempts` is clamped to at least 1, so the loop always runs.
None => abort("retry loop never ran")
}
}
///|
/// Whether a response is worth re-sending: the server was reachable but
/// asked the client to come back (429), or failed on its side (5xx).
fn is_transient(response : Response) -> Bool {
response.status == 429 || (response.status >= 500 && response.status < 600)
}
///|
/// Errors raised by `ChromaClient::send` keep their original shape, so the
/// retry loop only has to decide on HTTP statuses. A failure to send at all
/// is not retried: under a transport that raises instead of returning a
/// status there is no way to tell a dropped connection from a bad URL.
pub fn[T] RetryTransport::policy(self : RetryTransport[T]) -> RetryPolicy {
self.policy
}
///|
pub extend RetryPolicy with Eq::{not_equal, equal}
///|
pub extend RetryPolicy with @debug.Debug::{to_repr}
///|
pub extend RetryTransport with Transport::{send}