///|
/// The retry rule types of `gaato/http/retry`, re-exported so an SDK can
/// write and adjust a policy as `@runtime.X` without importing that package.
pub using @retry {
type RetryRule,
type StatusRule,
type StatusMatch,
type Methods,
type RetryAfter,
type RetryAfterSource,
type RetryAfterCap,
type Backoff,
type Jitter,
type Outcome,
type Decision,
type StopReason,
}
///|
/// A retry policy for `Client`: a `RetryRule` of `gaato/http/retry` applied
/// to `SdkError`s.
///
/// Start from a preset — `conservative()` (the default), `stainless(cap~)`,
/// `none()` — or from any rule with `from_rule`, and adjust it with
/// `with_max_retries`, `allow_non_idempotent` or a struct update of `rule`.
///
/// ```mbt check
/// test {
/// let policy = @runtime.RetryPolicy::conservative().with_max_retries(3)
/// assert_eq(policy.rule.max_retries, 3)
/// let post = @http.Request::post("/items")
/// let busy = @runtime.Status(status=503, headers=@http.Headers::new(), body=b"")
/// let context = @runtime.RetryContext::new(post, random=0.5)
/// assert_eq(policy.decide(busy, context), Stop(MethodNotAllowed))
/// assert_eq(policy.allow_non_idempotent().decide(busy, context), Wait(500))
/// }
/// ```
pub(all) struct RetryPolicy {
/// What is retried, how long to wait, and when to stop.
rule : RetryRule
} derive(Eq, Debug)
///|
/// Equality for retry policy values.
pub extend RetryPolicy with Eq::{equal, not_equal}
///|
/// Debug representation of retry policy values.
pub extend RetryPolicy with @debug.Debug::{to_repr}
///|
/// The policy `Client` uses unless told otherwise: two retries; 429 for any
/// method; 408, 409, 500, 502, 503 and 504 only for an idempotent request
/// (`@http.is_idempotent`, which accepts an `Idempotency-Key`); a failure
/// that proves nothing was sent (`HttpError::is_unsent`) for any method,
/// and a proxy's refusal only when its status is one of those; a timeout or
/// network failure after sending only for an idempotent request.
///
/// The wait is `retry-after-ms`, else `Retry-After` (seconds or an HTTP
/// date), and a server that asks for more than 60 s is not retried;
/// `Retry-After: 0` retries at once. Without one, the backoff starts at
/// 500 ms, doubles up to 30 s and is spread by ±20 %.
///
/// ```mbt check
/// test {
/// let policy = @runtime.RetryPolicy::conservative()
/// assert_eq(policy.rule.max_retries, 2)
/// assert_eq(policy.rule.backoff.delay_ms(1, 0.5), 1000)
/// assert_eq(@runtime.RetryPolicy::default(), policy)
/// }
/// ```
pub fn RetryPolicy::conservative() -> RetryPolicy {
{
rule: {
max_retries: 2,
statuses: [
{ statuses: [Code(429)], methods: Any, },
{
statuses: [
Code(408),
Code(409),
Code(500),
Code(502),
Code(503),
Code(504),
],
methods: Idempotent,
},
],
sent_failures: Idempotent,
unsent_failures: true,
override_header: None,
retry_after: {
source: MsThenHeader,
cap: StopAbove(60000),
zero_means_backoff: false,
},
backoff: {
base_ms: 500,
max_ms: 30000,
jitter: Proportional(lo=800, hi=1200),
},
},
}
}
///|
/// `conservative()`.
pub fn RetryPolicy::default() -> RetryPolicy {
RetryPolicy::conservative()
}
///|
/// The retries of the official Stainless-generated SDKs (`openai-python`
/// and `anthropic-sdk-python`, `_base_client.py`): two retries; 408, 409,
/// 429 and every 5xx for any method, as is any transport failure but a
/// protocol error; `x-should-retry: true` retries any response and `false`
/// none.
///
/// The wait is `retry-after-ms`, else `Retry-After`; `cap` decides what a
/// long one does (the OpenAI SDK gives up above two minutes,
/// `StopAbove(120000)`; the Anthropic SDK waits whatever it is told,
/// `NoCap`), and 0 means no delay was given. Without one, the backoff starts
/// at 500 ms, doubles up to 8 s and is shortened by up to 25 %.
///
/// Retrying a POST after a 5xx or a timeout can run it twice; the official
/// SDKs accept that for model APIs.
///
/// ```mbt check
/// test {
/// let policy = @runtime.RetryPolicy::stainless(cap=StopAbove(120000))
/// let post = @http.Request::post("/v1/responses")
/// let error = @runtime.Status(
/// status=500,
/// headers=@http.Headers::new(),
/// body=b"",
/// )
/// let context = @runtime.RetryContext::new(post, random=0.0)
/// assert_eq(policy.decide(error, context), Wait(375))
/// }
/// ```
pub fn RetryPolicy::stainless(cap~ : RetryAfterCap) -> RetryPolicy {
{
rule: {
max_retries: 2,
statuses: [
{
statuses: [Code(408), Code(409), Code(429), Range(500, 599)],
methods: Any,
},
],
sent_failures: Any,
unsent_failures: true,
override_header: Some("x-should-retry"),
retry_after: { source: MsThenHeader, cap, zero_means_backoff: true, },
backoff: {
base_ms: 500,
max_ms: 8000,
jitter: Proportional(lo=750, hi=1000),
},
},
}
}
///|
/// `conservative()` without retries.
pub fn RetryPolicy::none() -> RetryPolicy {
RetryPolicy::conservative().with_max_retries(0)
}
///|
/// A policy applying `rule` as it is.
pub fn RetryPolicy::from_rule(rule : RetryRule) -> RetryPolicy {
{ rule, }
}
///|
/// This policy with `max_retries` retries after the first attempt.
pub fn RetryPolicy::with_max_retries(
self : RetryPolicy,
max_retries : Int32,
) -> RetryPolicy {
{ rule: { ..self.rule, max_retries, }, }
}
///|
/// This policy with every `Idempotent` method set widened to `Any`, in its
/// status rules and for failures after sending: what `retry_non_idempotent:
/// true` did before 0.7.0. A retried POST may then run twice.
///
/// ```mbt check
/// test {
/// let policy = @runtime.RetryPolicy::conservative().allow_non_idempotent()
/// assert_eq(policy.rule.sent_failures, Any)
/// assert_true(policy.rule.statuses.iter().all(rule => rule.methods == Any))
/// }
/// ```
pub fn RetryPolicy::allow_non_idempotent(self : RetryPolicy) -> RetryPolicy {
let widen = (methods : Methods) => {
match methods {
Idempotent => Methods::Any
methods => methods
}
}
{
rule: {
..self.rule,
statuses: self.rule.statuses.map(rule => {
..rule,
methods: widen(rule.methods),
}),
sent_failures: widen(self.rule.sent_failures),
},
}
}
///|
/// Whether `Client` may send `request` again at all, before any
/// `RetryDecider` is asked: when each attempt builds its own body
/// (`rebuilt`), or the body can be sent again (`Body::is_replayable`: bytes,
/// or a stream no transport has read yet).
fn can_resend(request : @http.Request, rebuilt~ : Bool) -> Bool {
rebuilt || request.body.is_replayable()
}
///|
/// What a `RetryDecider` knows about the attempt that failed, besides the
/// error.
///
/// `Client` asks a decider only when it may send the request again, so the
/// `replayable` it passes is always true; the field is there for a decider
/// that hands the context on to `RetryRule::decide`, and for contexts made
/// with `RetryContext::new`.
pub struct RetryContext {
/// The rate-limit bucket the request was sent through.
bucket : String
/// The request as the client prepared it, without a deferred `body`.
request : @http.Request
/// The retries already made: 0 after the first attempt fails.
attempt : Int32
/// A random number in [0, 1), for jitter.
random : Double
/// The time the attempt ended, in Unix milliseconds, for HTTP-date
/// `Retry-After` values.
now_unix_ms : Int64
/// The time left before `Timeouts.total_ms` runs out, when one is set: a
/// wait at least this long cannot be followed by another attempt.
remaining_ms : Int64?
/// Whether the request can be sent again: its body is rebuilt for each
/// attempt or can be read again.
replayable : Bool
}
///|
/// A retry context, for tests and for deciders that consult another. By
/// default the bucket is `""`, the attempt `0`, `random` 0.5 (the middle of
/// any jitter), the time 0, no total timeout, and `replayable` is
/// `request.body.is_replayable()`.
///
/// ```mbt check
/// test {
/// let context = @runtime.RetryContext::new(@http.Request::get("/"))
/// assert_eq(context.attempt, 0)
/// assert_true(context.replayable)
/// }
/// ```
pub fn RetryContext::new(
request : @http.Request,
bucket~ : String = "",
attempt~ : Int32 = 0,
random~ : Double = 0.5,
now_unix_ms~ : Int64 = 0L,
remaining_ms? : Int64,
replayable? : Bool,
) -> RetryContext {
{
bucket,
request,
attempt,
random,
now_unix_ms,
remaining_ms,
replayable: replayable.unwrap_or_else(() => request.body.is_replayable()),
}
}
///|
/// Decides, after a failed attempt, whether the client sends the request
/// again and how long it waits first.
///
/// `RetryPolicy` is the general-purpose implementation. An API with rules a
/// `RetryRule` cannot express — a delay read from a response body, a limit
/// recognised by its message — supplies its own implementation to
/// `Client::new`, and can hand everything else to a `RetryPolicy`.
pub(open) trait RetryDecider {
/// `Wait(ms)` to send again after `ms` milliseconds, or `Stop(reason)` to
/// raise `error`.
fn decide(Self, SdkError, RetryContext) -> Decision
}
///|
/// The policy's decision as a dot-callable method.
pub extend RetryPolicy with RetryDecider::{decide}
///|
/// Applies the rule to `error.outcome()`: `Decode` and `Config` errors stop
/// with `NotRetryable`. A `RateLimited` error's `retry_after_ms`, when
/// present, is the server's delay unless the rule ignores server delays;
/// otherwise the rule reads the headers, with `now_unix_ms` for an HTTP
/// date.
pub impl RetryDecider for RetryPolicy with fn decide(self, error, context) {
guard error.outcome() is Some(outcome) else { return Stop(NotRetryable) }
let server_delay_ms = match error {
RateLimited(retry_after_ms=Some(ms), ..) if !(self.rule.retry_after.source
is Ignore) => Some(ms)
_ => None
}
self.rule.decide(
outcome,
context.request,
attempt=context.attempt,
random=context.random,
now_unix_ms=context.now_unix_ms,
remaining_ms?=context.remaining_ms,
server_delay_ms?,
replayable=context.replayable,
)
}