///|
/// The backoff strategy used between retries.
pub(all) enum Backoff {
/// Wait a constant `delay_ms` between attempts.
Constant(delay_ms~ : Int)
/// Exponential backoff: `base_ms * 2^attempt`, capped at `max_ms`.
Exponential(base_ms~ : Int, max_ms~ : Int)
/// Exponential backoff with deterministic jitter (a fraction of the delay
/// derived from the attempt number, to avoid a true RNG dependency).
ExponentialJitter(base_ms~ : Int, max_ms~ : Int)
} derive(Eq, Debug)
///|
/// A retry policy: how many times to retry and how long to wait between tries.
pub(all) struct RetryPolicy {
max_retries : Int
backoff : Backoff
/// Whether to honor a server-provided `Retry-After` value when present.
respect_retry_after : Bool
}
///|
/// A sensible default policy: 3 retries with exponential backoff from 500ms,
/// capped at 8s, honoring `Retry-After`.
pub fn RetryPolicy::default() -> RetryPolicy {
{
max_retries: 3,
backoff: Exponential(base_ms=500, max_ms=8000),
respect_retry_after: true,
}
}
///|
/// Construct a custom retry policy.
pub fn RetryPolicy::new(
max_retries : Int,
backoff : Backoff,
respect_retry_after? : Bool = true,
) -> RetryPolicy {
{ max_retries, backoff, respect_retry_after }
}
///|
/// Compute the delay in milliseconds before the given zero-based `attempt`
/// (0 = the wait before the first retry).
pub fn RetryPolicy::delay_for(self : RetryPolicy, attempt : Int) -> Int {
match self.backoff {
Constant(delay_ms~) => delay_ms
Exponential(base_ms~, max_ms~) => exp_delay(base_ms, max_ms, attempt)
ExponentialJitter(base_ms~, max_ms~) => {
let base = exp_delay(base_ms, max_ms, attempt)
// Deterministic jitter: subtract up to ~25% based on the attempt index.
let jitter = base / 4 * (attempt % 3) / 3
base - jitter
}
}
}
///|
/// Exponential delay `base * 2^attempt`, clamped to `max`, guarding overflow.
fn exp_delay(base_ms : Int, max_ms : Int, attempt : Int) -> Int {
let mut delay = base_ms
let mut i = 0
while i < attempt {
delay = delay * 2
if delay >= max_ms {
return max_ms
}
i = i + 1
}
if delay > max_ms {
max_ms
} else {
delay
}
}
///|
/// Parse a `Retry-After` header value into milliseconds.
///
/// Supports the delta-seconds form (e.g. `"5"` → 5000ms). The HTTP-date form
/// is not supported and yields `None`.
pub fn parse_retry_after(value : String) -> Int? {
let secs = @string.parse_int(value) catch { _ => return None }
if secs >= 0 {
Some(secs * 1000)
} else {
None
}
}
///|
/// Whether an error is retryable under this policy.
pub fn is_retryable_error(err : LLMError) -> Bool {
match err {
Transport(_) => true
ApiError(code~, ..) => code == 429 || code >= 500
_ => false
}
}
///|
/// Perform a chat completion with retries governed by an explicit policy.
///
/// On a retryable `ApiError`, if the policy honors `Retry-After` and the
/// error message contains a parseable hint, that delay is preferred over the
/// computed backoff.
pub async fn Client::chat_with_policy(
self : Client,
request : ChatRequest,
policy : RetryPolicy,
) -> ChatResponse raise LLMError {
let mut attempt = 0
for ;; {
let result = Ok(self.chat(request)) catch { err => Err(err) }
match result {
Ok(resp) => return resp
Err(err) => {
if attempt >= policy.max_retries || !is_retryable_error(err) {
raise err
}
let delay = policy.delay_for(attempt)
@async.sleep(delay) catch {
e => raise Transport("interrupted during backoff: " + e.to_string())
}
attempt = attempt + 1
}
}
}
}