///|
/// Reserved header stripped from caller-supplied headers before the backend
/// sees them. Retry safety uses the typed `replay_safe` attribute instead, so
/// a caller cannot forge safety with a wire header.
const REPLAY_SAFE_HEADER : String = "x-discord-mbt-replay-safe"
///|
/// Discord's resend rule as a `gaato/mttp/client/retry` rule, up to `max_retries`
/// resends:
///
/// - a 429 for any method, after the delay Discord asked for (supplied by
/// `resend_decision`, uncapped: the caller's deadline bounds it);
/// - a 502 for any method: Discord documents it as "wait a bit and retry";
/// - any other 5xx only for an idempotent request, since Discord does not
/// promise that a failed `POST` or `PATCH` did nothing;
/// - a transport failure that proves nothing was sent, for any method, and
/// one after sending only for an idempotent request.
///
/// Other statuses, 401 and 403 among them, are Discord's answer. Retries
/// that the response does not time back off as the runtime's conservative
/// preset does: 500 ms doubling to 30 s, within ±20 %.
fn discord_retry_rule(max_retries : Int32) -> @retry.RetryRule {
{
max_retries,
// First match wins: 502 before the idempotent-only 5xx range.
statuses: [
{ statuses: [Code(429), Code(502)], methods: Any, },
{ statuses: [Range(500, 599)], methods: Idempotent, },
],
sent_failures: Idempotent,
unsent_failures: true,
override_header: None,
retry_after: {
source: MsThenHeader,
cap: NoCap,
zero_means_backoff: false,
},
backoff: @runtime.RetryPolicy::conservative().rule.backoff,
}
}
///|
/// Whether to send a call again after a failed attempt, and after how long.
/// Nothing is resent once the client is `closed`. Otherwise
/// `discord_retry_rule(max_retries)` decides, with two Discord-specific
/// inputs:
///
/// - a 429's delay is the body's `retry_after` (fractional seconds), then
/// `Retry-After-Ms` or `Retry-After`, then one second, plus one
/// millisecond; but a 429 that is not Discord's rate limit
/// (`@ratelimit.is_discord_rate_limit`: no JSON content type and no
/// `X-RateLimit-Scope` or `X-RateLimit-Global`, as from Cloudflare in front
/// of Discord) gives no wait to honour, and resending it only adds to the
/// address's invalid requests, so it fails at once;
/// - a call is idempotent when its method is (`GET`, `HEAD`, `PUT`,
/// `DELETE`, `OPTIONS`) or when its typed `replay_safe` attribute is true.
fn resend_decision(
attempt : @runtime.AttemptResult[Result[Bytes, @runtime.ResponseFailure]],
context : @runtime.RetryContext,
max_retries~ : Int32,
closed~ : Bool,
) -> @retry.Decision {
if closed {
return Stop(NotRetryable)
}
let outcome = attempt.http_outcome()
let server_delay_ms = match attempt {
Buffered(reply) if reply.status == 429 => {
let head : @ghttp.ResponseHead = {
status: reply.status,
headers: reply.headers,
}
let body = match reply.body {
Ok(body) => body
Err(
@runtime.UnexpectedStatus(_, body~)
| @runtime.Deserialization(_, body~, ..)
) => body
Err(@runtime.ResponseFailure::..) => b""
}
let headers = head.headers
if !@ratelimit.is_discord_rate_limit(headers) {
return Stop(NotRetryable)
}
let (retry_after_ms, _) = rate_limit_from_json(
headers,
json_body(body).unwrap_or(Json::null()),
)
Some(retry_after_ms.max(0L).min(2147483646L).to_int() + 1)
}
_ => None
}
let request = context.request
discord_retry_rule(max_retries).decide(
outcome,
request,
attempt=context.attempt,
random=context.random,
now_unix_ms=context.now_unix_ms,
remaining_ms?=context.remaining_ms,
server_delay_ms?,
idempotent=context.idempotent,
replayable=context.replayable,
)
}
///|
/// A response body as JSON, or `None` when it is not JSON.
fn json_body(body : Bytes) -> Json? {
if body.is_empty() {
return None
}
Some(@json.parse(@utf8.decode_lossy(body))) catch {
_ => None
}
}