///|
/// GitHub's retry rule: the rate limits GitHub documents are waited out as
/// docs.github.com asks, a transient GraphQL failure of a query is retried,
/// and everything else follows `policy`.
///
/// `GitHub::new` wraps its `retry` policy in this, by default
/// `RetryPolicy::conservative().with_max_retries(3)`: three retries, as
/// octokit's `plugin-retry`, but a 5xx or a timeout after sending is retried
/// only for an idempotent request, because GitHub has no idempotency key and a
/// resent POST can open a second issue or comment.
pub(all) struct GitHubRetry {
policy : @runtime.RetryPolicy
}
///|
/// Exposes the retry decision as a method.
pub extend GitHubRetry with @runtime.RetryDecider::{decide}
///|
/// Decides a retry, recognising three GitHub conditions before handing the
/// error to `policy`; each of them is retried for any method, because the
/// request was rejected rather than processed.
///
/// - **Secondary rate limit** (`is_secondary_rate_limit`: a `403` or `429`
/// with `retry-after` or a message naming the secondary rate limit, which
/// a GraphQL `200` also carries): waits `retry-after` (seconds or an HTTP
/// date), or, when it is absent or not positive, 60 s doubled for each
/// retry already made ("wait for at least one minute", "an exponentially
/// increasing amount of time"). The policy's Retry-After cap does not
/// apply; its retry count and the call's remaining time
/// (`RetryContext::remaining_ms`, stopping with `Budget`) do.
/// - **Primary rate limit** (a `403` or `429` with `x-ratelimit-remaining: 0`,
/// or a GraphQL `RATE_LIMITED` error): waits until one second past
/// `x-ratelimit-reset` ("you should not retry your request until after"
/// it), and only once per call, as octokit does; a second one stops with
/// `Exhausted`. Without a readable reset it stops with `NotRetryable`
/// rather than guess, and the policy's retry count and the remaining time
/// bound it as above.
/// - **Transient GraphQL failure** ("Something went wrong while executing
/// your query", raised as `Decode` by `graphql` for a document that is not
/// a mutation, in the `"graphql"` bucket): waits the policy's backoff, within
/// its retry count and the remaining time. A mutation's (`"graphql:mutation"`)
/// is never retried, since it may have been applied.
///
/// Every other error, including the cases of `policy.rule.retry_after.cap`,
/// is decided by `policy` alone.
///
/// ```mbt check
/// test {
/// let retry : @github.GitHubRetry = {
/// policy: @runtime.RetryPolicy::conservative().with_max_retries(3),
/// }
/// let post = @http.Request::post("/repos/o/r/issues")
/// let limited = @runtime.Status(
/// status=403,
/// headers=@http.Headers::new(),
/// body=b"{\"message\":\"You have exceeded a secondary rate limit.\"}",
/// )
/// let waits = [0, 1, 2, 3].map(attempt => {
/// retry.decide(limited, @runtime.RetryContext::new(post, attempt~))
/// })
/// assert_eq(waits, [Wait(60000), Wait(120000), Wait(240000), Stop(Exhausted)])
/// }
/// ```
pub impl @runtime.RetryDecider for GitHubRetry with fn decide(
self,
error,
context,
) {
match github_condition(error, context.bucket) {
Some(Secondary(headers)) => self.secondary_decision(headers, context)
Some(Primary(headers)) => self.primary_decision(headers, context)
Some(TransientGraphql) => self.transient_decision(context)
None => self.policy.decide(error, context)
}
}
///|
/// A failure GitHub's own rules decide, with the headers its wait is read
/// from.
priv enum GitHubCondition {
Secondary(@http.Headers)
Primary(@http.Headers)
TransientGraphql
}
///|
/// Recognises the conditions `GitHubRetry` decides itself. The secondary limit
/// is checked first: a `403` that names it is not the hourly quota even when
/// that quota is also spent.
fn github_condition(
error : @runtime.SdkError,
bucket : String,
) -> GitHubCondition? {
let (headers, body) = match error {
@runtime.Status(status=403 | 429, headers~, body~)
| @runtime.RateLimited(headers~, body~, ..) => (headers, body)
@runtime.Decode(body~, ..) =>
return if bucket == graphql_bucket &&
graphql_reply_has(body, GraphqlTransient) {
Some(TransientGraphql)
} else {
None
}
_ => return None
}
if is_secondary_rate_limit(error) {
Some(Secondary(headers))
} else if headers.get("x-ratelimit-remaining").map(value => value.trim()) ==
Some("0") ||
graphql_reply_has(body, GraphqlRateLimited) {
Some(Primary(headers))
} else {
None
}
}
///|
/// The checks every GitHub-specific retry shares: the policy's retry count
/// and a body that can be sent again.
fn GitHubRetry::refusal(
self : GitHubRetry,
context : @runtime.RetryContext,
) -> @runtime.Decision? {
if context.attempt >= self.policy.rule.max_retries {
Some(Stop(Exhausted))
} else if !context.replayable {
Some(Stop(NotReplayable))
} else {
None
}
}
///|
/// Waits `delay` milliseconds unless that would use up the call's remaining
/// time.
fn within_budget(
delay : Int32,
context : @runtime.RetryContext,
) -> @runtime.Decision {
if context.remaining_ms is Some(remaining) && delay.to_int64() >= remaining {
Stop(Budget(delay))
} else {
Wait(delay)
}
}
///|
/// The secondary-limit wait: `retry-after` when positive, else 60 s doubled
/// per retry already made, saturating at the longest `Int` wait.
fn GitHubRetry::secondary_decision(
self : GitHubRetry,
headers : @http.Headers,
context : @runtime.RetryContext,
) -> @runtime.Decision {
if self.refusal(context) is Some(decision) {
return decision
}
let delay = match
@http.retry_after_ms(headers, now_unix_ms=context.now_unix_ms) {
Some(ms) if ms > 0 => ms
_ => {
let mut ms = 60000L
for _ in 0.. @runtime.Decision {
if self.refusal(context) is Some(decision) {
return decision
}
if context.attempt > 0 {
return Stop(Exhausted)
}
guard headers.get("x-ratelimit-reset").bind(parse_unix_seconds) is Some(reset) else {
return Stop(NotRetryable)
}
within_budget(primary_wait_ms(reset, context.now_unix_ms), context)
}
///|
/// `max(ceil((reset - now) / 1 s) + 1 s, 0)` in milliseconds, saturating at
/// the longest `Int` wait. `reset` is in Unix seconds, `now` in Unix
/// milliseconds.
fn primary_wait_ms(reset_unix_seconds : Int64, now_unix_ms : Int64) -> Int32 {
// Bounded by the parser, so neither product overflows.
let delta_ms = reset_unix_seconds * 1000L - now_unix_ms
let seconds = if delta_ms > 0L {
(delta_ms + 999L) / 1000L
} else {
// Integer division truncates toward zero, which is the ceiling here.
delta_ms / 1000L
}
let wait = (seconds + 1L).max(0L) * 1000L
wait.min(2147483647L).to_int()
}
///|
/// Reads a non-negative whole number of Unix seconds, as GitHub writes
/// `x-ratelimit-reset`; `None` for anything else, or a value past year 2⁴⁰.
fn parse_unix_seconds(value : String) -> Int64? {
let value = value.trim()
if value.is_empty() {
return None
}
let mut seconds = 0L
for c in value {
guard c >= '0' && c <= '9' else { return None }
seconds = seconds * 10L + (c.to_int() - 48).to_int64()
if seconds > 1099511627776L {
return None
}
}
Some(seconds)
}
///|
/// The transient GraphQL wait: the policy's backoff for the retry about to be
/// made.
fn GitHubRetry::transient_decision(
self : GitHubRetry,
context : @runtime.RetryContext,
) -> @runtime.Decision {
if self.refusal(context) is Some(decision) {
return decision
}
within_budget(
self.policy.rule.backoff.delay_ms(context.attempt, context.random),
context,
)
}