// Retry decisions as data. A `RetryRule` says which responses and transport
// failures may be retried, for which methods, how long to wait and when to
// give up; `RetryRule::decide` turns one failed attempt into `Wait(ms)` or
// `Stop(reason)`. This package decides; it never sleeps, sends or reads a
// clock, so the same rule gives the same answer on every backend. The caller
// injects `random` (for jitter), the current time (for HTTP-date
// Retry-After values) and the time left in its budget.
//
// The rule holds no vendor defaults: presets live in the layers that own the
// policy, such as `gaato/sdk-runtime` (the behaviour of an official API SDK)
// and `gaato/requesto` (a client for people).
///|
/// A status code, or an inclusive range of status codes.
///
/// ```mbt check
/// test {
/// let rule : @retry.StatusRule = {
/// statuses: [Code(429), Range(500, 599)],
/// methods: Any,
/// }
/// assert_eq(rule.statuses.length(), 2)
/// }
/// ```
pub(all) enum StatusMatch {
/// Exactly this status.
Code(Int32)
/// Every status from the first to the second, both included.
Range(Int32, Int32)
} derive(Eq, Debug)
///|
/// Equality for status matches.
pub extend StatusMatch with Eq::{equal, not_equal}
///|
/// Debug representation of a status match.
pub extend StatusMatch with @debug.Debug::{to_repr}
///|
fn StatusMatch::contains(self : StatusMatch, status : Int32) -> Bool {
match self {
Code(code) => status == code
Range(lo, hi) => status >= lo && status <= hi
}
}
///|
/// Which request methods a retry is allowed for.
#non_exhaustive
pub(all) enum Methods {
/// No method.
Never
/// Requests that `@http.is_idempotent` accepts (an idempotent method or
/// an `Idempotency-Key` header), unless the caller says otherwise with
/// `decide`'s `idempotent` argument.
Idempotent
/// These methods, compared ignoring ASCII case.
Listed(Array[String])
/// Every method.
Any
} derive(Eq, Debug)
///|
/// Equality for method sets.
pub extend Methods with Eq::{equal, not_equal}
///|
/// Debug representation of a method set.
pub extend Methods with @debug.Debug::{to_repr}
///|
/// Response statuses that may be retried, and for which methods.
pub(all) struct StatusRule {
/// The statuses this rule covers.
statuses : Array[StatusMatch]
/// The methods a response with one of those statuses may be retried for.
methods : Methods
} derive(Eq, Debug)
///|
/// Equality for status rules.
pub extend StatusRule with Eq::{equal, not_equal}
///|
/// Debug representation of a status rule.
pub extend StatusRule with @debug.Debug::{to_repr}
///|
/// Where a server-requested delay is read from.
#non_exhaustive
pub(all) enum RetryAfterSource {
/// Nowhere: the backoff always decides.
Ignore
/// The `Retry-After` header, as `@http.parse_retry_after` reads it.
Header
/// `retry-after-ms`, then `Retry-After`, as `@http.retry_after_ms` reads
/// them (the OpenAI SDKs' behaviour).
MsThenHeader
} derive(Eq, Debug)
///|
/// Equality for delay sources.
pub extend RetryAfterSource with Eq::{equal, not_equal}
///|
/// Debug representation of a delay source.
pub extend RetryAfterSource with @debug.Debug::{to_repr}
///|
/// What to do with a server-requested delay longer than a limit, in
/// milliseconds.
#non_exhaustive
pub(all) enum RetryAfterCap {
/// Give up (`StopReason::RetryAfterTooLong`) rather than wait longer.
StopAbove(Int32)
/// Ignore the server and wait the backoff delay instead.
BackoffAbove(Int32)
/// Wait as long as the server asks.
NoCap
} derive(Eq, Debug)
///|
/// Equality for delay caps.
pub extend RetryAfterCap with Eq::{equal, not_equal}
///|
/// Debug representation of a delay cap.
pub extend RetryAfterCap with @debug.Debug::{to_repr}
///|
/// How a server-requested delay is read and bounded.
pub(all) struct RetryAfter {
/// Where the delay comes from.
source : RetryAfterSource
/// What a delay over the limit does.
cap : RetryAfterCap
/// Whether a delay of 0 means "use the backoff" rather than "retry now".
zero_means_backoff : Bool
} derive(Eq, Debug)
///|
/// Equality for delay settings.
pub extend RetryAfter with Eq::{equal, not_equal}
///|
/// Debug representation of delay settings.
pub extend RetryAfter with @debug.Debug::{to_repr}
///|
/// How the exponential delay is spread, so that clients do not retry in
/// lockstep. Multipliers are per mille.
#non_exhaustive
pub(all) enum Jitter {
/// The exponential delay itself.
NoJitter
/// A uniform delay in [0, delay), as in AWS's "full jitter".
Full
/// The delay times a multiplier from `lo` to `hi` per mille: (800, 1200)
/// is ±20 %, (750, 1000) is up to 25 % shorter.
Proportional(lo~ : Int32, hi~ : Int32)
} derive(Eq, Debug)
///|
/// Equality for jitter settings.
pub extend Jitter with Eq::{equal, not_equal}
///|
/// Debug representation of a jitter setting.
pub extend Jitter with @debug.Debug::{to_repr}
///|
/// Exponential backoff: `base_ms` doubled per attempt up to `max_ms`, then
/// jittered.
pub(all) struct Backoff {
/// The delay before the first retry, before jitter.
base_ms : Int32
/// The largest delay before jitter.
max_ms : Int32
/// How the delay is spread.
jitter : Jitter
} derive(Eq, Debug)
///|
/// Equality for backoff settings.
pub extend Backoff with Eq::{equal, not_equal}
///|
/// Debug representation of backoff settings.
pub extend Backoff with @debug.Debug::{to_repr}
///|
/// A retry policy as data: what may be retried, how long to wait, and when
/// to stop. `decide` applies it to one failed attempt.
///
/// ```mbt check
/// test {
/// let rule : @retry.RetryRule = {
/// ..@retry.RetryRule::never(),
/// max_retries: 2,
/// statuses: [{ statuses: [Code(429), Range(500, 599)], methods: Idempotent, }],
/// backoff: { base_ms: 500, max_ms: 8000, jitter: NoJitter, },
/// }
/// let get = @http.Request::get("/items")
/// let busy = @retry.Response(status=503, headers=@http.Headers::new())
/// assert_eq(rule.decide(busy, get, attempt=1, random=0.0), Wait(1000))
/// assert_eq(rule.decide(busy, get, attempt=2, random=0.0), Stop(Exhausted))
/// }
/// ```
pub(all) struct RetryRule {
/// How many retries follow the first attempt.
max_retries : Int32
/// Retryable response statuses; the first rule containing a status wins.
statuses : Array[StatusRule]
/// For which methods a failure after the request may have been sent (a
/// timeout or network error once connected) is retried.
sent_failures : Methods
/// Whether a failure that proves nothing was sent (`HttpError::is_unsent`)
/// is retried, whatever the method. A proxy's refusal is retried only when
/// its status also matches `statuses`.
unsent_failures : Bool
/// A response header that overrides the decision: `true` retries any
/// method and status, `false` stops (e.g. `x-should-retry`).
override_header : String?
/// How a server-requested delay is used.
retry_after : RetryAfter
/// The delay when the server asks for none.
backoff : Backoff
} derive(Eq, Debug)
///|
/// Equality for retry rules.
pub extend RetryRule with Eq::{equal, not_equal}
///|
/// Debug representation of a retry rule.
pub extend RetryRule with @debug.Debug::{to_repr}
///|
/// A rule that never retries, and a base to build others on with `{ ..never(), ... }`.
pub fn RetryRule::never() -> RetryRule {
{
max_retries: 0,
statuses: [],
sent_failures: Never,
unsent_failures: false,
override_header: None,
retry_after: { source: Ignore, cap: NoCap, zero_means_backoff: false, },
backoff: { base_ms: 0, max_ms: 0, jitter: NoJitter, },
}
}
///|
/// The result of an attempt that did not succeed.
pub(all) enum Outcome {
/// A response whose status the caller treats as a failure.
Response(status~ : Int32, headers~ : @http.Headers)
/// A transport failure.
Failure(@http.HttpError)
} derive(Debug)
///|
/// Debug representation of an outcome.
pub extend Outcome with @debug.Debug::{to_repr}
///|
/// Why `decide` stopped.
#non_exhaustive
pub(all) enum StopReason {
/// `max_retries` retries were already made.
Exhausted
/// The status or failure is not retryable under the rule.
NotRetryable
/// The status or failure is retryable, but not for this request's method.
MethodNotAllowed
/// The request body cannot be sent again.
NotReplayable
/// The override header said `false`.
ServerSaidNo
/// The server asked for this many milliseconds, over the `Stop` cap.
RetryAfterTooLong(Int32)
/// Waiting this many milliseconds would use up the remaining budget.
Budget(Int32)
} derive(Eq, Debug)
///|
/// Equality for stop reasons.
pub extend StopReason with Eq::{equal, not_equal}
///|
/// Debug representation of a stop reason.
pub extend StopReason with @debug.Debug::{to_repr}
///|
/// What to do after a failed attempt.
pub(all) enum Decision {
/// Wait this many milliseconds, then send the request again.
Wait(Int32)
/// Do not retry.
Stop(StopReason)
} derive(Eq, Debug)
///|
/// Equality for decisions.
pub extend Decision with Eq::{equal, not_equal}
///|
/// Debug representation of a decision.
pub extend Decision with @debug.Debug::{to_repr}