use std::time::Duration;
use crate::error::{ClientError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Repeatable {
Freely,
WithMutationId,
Never,
Heavy,
}
const RETRIABLE_CODES: &[i64] = &[
3, 100, 105, 108, 904, 2100, ];
const RETRIABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
const CERTIFICATE_VERDICT: &str = "invalid peer certificate: ";
const SETTLED_REJECTIONS: &[&str] = &[
"UnknownIssuer",
"NotValidForName",
"certificate not valid for name ",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
attempts: u32,
initial_backoff: Duration,
max_backoff: Duration,
report: bool,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
attempts: 5,
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(10),
report: true,
}
}
}
impl RetryPolicy {
#[must_use]
pub fn new(attempts: u32, initial_backoff: Duration, max_backoff: Duration) -> Self {
Self {
attempts: attempts.max(1),
initial_backoff,
max_backoff,
report: true,
}
}
#[must_use]
pub fn none() -> Self {
Self::new(1, Duration::ZERO, Duration::ZERO)
}
#[must_use]
pub fn quiet(mut self) -> Self {
self.report = false;
self
}
#[must_use]
pub fn loud(mut self) -> Self {
self.report = true;
self
}
pub(crate) fn reports(self) -> bool {
self.report
}
fn backoff(self, attempt: u32) -> Duration {
let doubled = self
.initial_backoff
.checked_mul(1_u32.checked_shl(attempt - 1).unwrap_or(u32::MAX))
.unwrap_or(self.max_backoff);
doubled.min(self.max_backoff)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MutationId {
id: String,
retry: bool,
}
impl MutationId {
#[must_use]
pub fn new() -> Self {
Self {
id: generate(),
retry: false,
}
}
#[must_use]
pub fn as_retry(&self) -> Self {
Self {
id: self.id.clone(),
retry: true,
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.id
}
#[must_use]
pub fn is_retry(&self) -> bool {
self.retry
}
}
impl Default for MutationId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for MutationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.id)
}
}
fn generate() -> String {
let mut parts = [0_u32; 4];
for (i, pair) in parts.chunks_mut(2).enumerate() {
let value = crate::unique::word(i as u64);
pair[0] = (value >> 32) as u32;
pair[1] = value as u32;
}
format!(
"{:x}-{:x}-{:x}-{:x}",
parts[0], parts[1], parts[2], parts[3]
)
}
pub(crate) fn is_retriable(error: &ClientError) -> bool {
match error {
ClientError::Transport { source, .. } => !rejected_the_certificate(source),
ClientError::Http { status, .. } => RETRIABLE_STATUSES.contains(status),
ClientError::Cluster { code, raw, .. } => {
RETRIABLE_CODES.contains(code) || raw_contains_code(raw, RETRIABLE_CODES)
}
_ => false,
}
}
fn rejected_the_certificate(error: &ureq::Error) -> bool {
settled_certificate_verdict(error).is_some()
}
pub(crate) fn settled_certificate_verdict(error: &ureq::Error) -> Option<&'static str> {
let ureq::Error::Io(io) = error else {
return None;
};
if io.kind() != std::io::ErrorKind::InvalidData {
return None;
}
let message = io.to_string();
let (_, reason) = message.split_once(CERTIFICATE_VERDICT)?;
SETTLED_REJECTIONS
.iter()
.find(|settled| reason.starts_with(*settled))
.copied()
}
pub(crate) fn raw_contains_code(raw: &str, wanted: &[i64]) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
return false;
};
contains_code(&value, wanted)
}
fn contains_code(value: &serde_json::Value, wanted: &[i64]) -> bool {
if let Some(code) = value.get("code").and_then(serde_json::Value::as_i64)
&& wanted.contains(&code)
{
return true;
}
value
.get("inner_errors")
.and_then(serde_json::Value::as_array)
.is_some_and(|inner| inner.iter().any(|error| contains_code(error, wanted)))
}
pub(crate) fn worth_asking_again(error: &ClientError) -> bool {
is_retriable(error)
|| refused_for_being_the_wrong_proxy(error)
|| matches!(error, ClientError::Redirected { .. })
}
pub(crate) fn attributable_to_the_host(error: &ClientError) -> bool {
matches!(error, ClientError::Transport { source, .. } if rejected_the_certificate(source))
|| worth_asking_again(error)
}
fn refused_for_being_the_wrong_proxy(error: &ClientError) -> bool {
matches!(
error,
ClientError::Cluster { message, .. } if message.contains(crate::http::CONTROL_REFUSAL)
)
}
pub(crate) fn report_by_default() -> bool {
!inside_job(std::env::var_os("YT_JOB_ID"))
}
fn inside_job(job_id: Option<std::ffi::OsString>) -> bool {
job_id.is_some_and(|id| !id.is_empty())
}
pub(crate) fn run<T>(
policy: RetryPolicy,
repeatable: Repeatable,
command: &str,
mut action: impl FnMut(bool) -> Result<T>,
) -> Result<T> {
let allowed = match repeatable {
Repeatable::Never | Repeatable::Heavy => 1,
_ => policy.attempts,
};
let mut attempt = 1;
loop {
match crate::observe::attempt(command, attempt, || action(attempt > 1)) {
Ok(value) => return Ok(value),
Err(error) => {
if attempt >= allowed || !is_retriable(&error) {
return Err(error);
}
let wait = policy.backoff(attempt);
if policy.report {
crate::observe::retrying(command, &error, wait, attempt, allowed);
}
std::thread::sleep(wait);
attempt += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn instant(attempts: u32) -> RetryPolicy {
RetryPolicy::new(attempts, Duration::ZERO, Duration::ZERO)
}
fn cluster_error(code: i64, raw: &str) -> ClientError {
ClientError::Cluster {
command: "get".to_owned(),
code,
message: "boom".to_owned(),
raw: raw.to_owned(),
}
}
#[test]
fn an_unavailable_cluster_is_worth_retrying() {
assert!(is_retriable(&cluster_error(105, r#"{"code":105}"#)));
}
#[test]
fn a_wrapper_error_is_judged_by_what_is_inside_it() {
let raw = r#"{"code":1,"message":"Request retries failed",
"inner_errors":[{"code":105,"message":"Master is not connected"}]}"#;
assert!(is_retriable(&cluster_error(1, raw)));
}
#[test]
fn a_mistake_is_not_retried() {
assert!(!is_retriable(&cluster_error(500, r#"{"code":500}"#)));
assert!(!is_retriable(&cluster_error(501, r#"{"code":501}"#)));
assert!(!is_retriable(&cluster_error(1, r#"{"code":1}"#)));
}
#[test]
fn an_unparseable_error_document_is_not_retried() {
assert!(!is_retriable(&cluster_error(1, "not json at all")));
}
#[test]
fn http_statuses_are_split_by_whether_waiting_helps() {
let http = |status| ClientError::Http {
command: "get".to_owned(),
status,
body: String::new(),
};
assert!(is_retriable(&http(503)));
assert!(is_retriable(&http(429)));
assert!(!is_retriable(&http(404)));
assert!(!is_retriable(&http(401)));
}
fn transport_error(kind: std::io::ErrorKind, message: &str) -> ClientError {
ClientError::Transport {
command: "get".to_owned(),
source: Box::new(ureq::Error::Io(std::io::Error::new(kind, message))),
}
}
#[test]
fn a_rejected_certificate_is_not_retried() {
assert!(!is_retriable(&transport_error(
std::io::ErrorKind::InvalidData,
"invalid peer certificate: UnknownIssuer"
)));
for rejection in [
"invalid peer certificate: NotValidForName",
"invalid peer certificate: certificate not valid for name \
\"cluster.example.net\"; certificate is only valid for \
DnsName(\"other.example.net\")",
] {
assert!(
!is_retriable(&transport_error(std::io::ErrorKind::InvalidData, rejection)),
"{rejection}"
);
}
}
#[test]
fn a_platform_verifier_that_had_a_bad_afternoon_is_retried() {
for message in [
"invalid peer certificate: Other(OtherError(TrustStoreUnavailable))",
"invalid peer certificate: Other(OtherError(RevocationLookupTimedOut))",
"invalid peer certificate: Other(OtherError(\"UnknownIssuer lookup failed\"))",
] {
assert!(
is_retriable(&transport_error(std::io::ErrorKind::InvalidData, message)),
"{message}"
);
}
}
#[test]
fn a_certificate_that_may_be_one_proxy_out_of_several_is_retried() {
for message in [
"invalid peer certificate: Expired",
"invalid peer certificate: NotValidYet",
"invalid peer certificate: Revoked",
"invalid certificate revocation list: ParseError",
"peer sent no certificates",
] {
assert!(
is_retriable(&transport_error(std::io::ErrorKind::InvalidData, message)),
"{message}"
);
}
}
#[test]
fn every_other_transport_failure_is_still_retried() {
for (kind, message) in [
(
std::io::ErrorKind::ConnectionReset,
"connection reset by peer",
),
(std::io::ErrorKind::ConnectionRefused, "connection refused"),
(std::io::ErrorKind::TimedOut, "operation timed out"),
(std::io::ErrorKind::UnexpectedEof, "unexpected end of file"),
(
std::io::ErrorKind::InvalidData,
"received corrupt message of type Handshake",
),
(
std::io::ErrorKind::InvalidData,
"peer misbehaved: TooManyEmptyFragments",
),
(
std::io::ErrorKind::Other,
"invalid peer certificate: UnknownIssuer",
),
] {
assert!(is_retriable(&transport_error(kind, message)), "{message}");
}
assert!(is_retriable(&ClientError::Transport {
command: "get".to_owned(),
source: Box::new(ureq::Error::HostNotFound),
}));
}
#[test]
fn a_rejected_certificate_costs_one_attempt_and_not_five() {
let calls = std::cell::Cell::new(0);
let result: Result<()> = run(instant(5), Repeatable::Freely, "get", |_| {
calls.set(calls.get() + 1);
Err(transport_error(
std::io::ErrorKind::InvalidData,
"invalid peer certificate: UnknownIssuer",
))
});
assert!(result.is_err());
assert_eq!(
calls.get(),
1,
"a certificate is no likelier to be accepted on the fifth try"
);
}
#[test]
fn asking_again_is_a_different_question_from_waiting() {
for worth_waiting in [
ClientError::Transport {
command: "write_table".to_owned(),
source: Box::new(ureq::Error::HostNotFound),
},
ClientError::Http {
command: "hosts".to_owned(),
status: 503,
body: String::new(),
},
cluster_error(2100, r#"{"code":2100}"#),
] {
assert!(is_retriable(&worth_waiting), "{worth_waiting}");
assert!(worth_asking_again(&worth_waiting), "{worth_waiting}");
}
let wrong_proxy = ClientError::Cluster {
command: "write_table".to_owned(),
code: 1,
message: "Control proxy may not serve heavy requests with input data".to_owned(),
raw: r#"{"code":1}"#.to_owned(),
};
assert!(!is_retriable(&wrong_proxy), "{wrong_proxy}");
assert!(worth_asking_again(&wrong_proxy), "{wrong_proxy}");
for settled in [
ClientError::Http {
command: "hosts".to_owned(),
status: 404,
body: String::new(),
},
ClientError::Decode {
command: "hosts".to_owned(),
reason: "not a list of host names".to_owned(),
},
ClientError::Config("no proxy".to_owned()),
cluster_error(500, r#"{"code":500}"#),
] {
assert!(!is_retriable(&settled), "{settled}");
assert!(!worth_asking_again(&settled), "{settled}");
}
}
#[test]
fn a_rejected_certificate_is_the_hosts_fault_though_not_worth_waiting_or_asking() {
for spelling in [
"invalid peer certificate: UnknownIssuer",
"invalid peer certificate: certificate not valid for name \"n0132.example.net\"; \
certificate is only valid for [\"cluster.example.net\"]",
"invalid peer certificate: NotValidForName",
] {
let rejected = transport_error(std::io::ErrorKind::InvalidData, spelling);
assert!(!is_retriable(&rejected), "{spelling}");
assert!(!worth_asking_again(&rejected), "{spelling}");
assert!(attributable_to_the_host(&rejected), "{spelling}");
}
for hosts_fault in [
ClientError::Transport {
command: "write_table".to_owned(),
source: Box::new(ureq::Error::HostNotFound),
},
ClientError::Http {
command: "write_table".to_owned(),
status: 503,
body: String::new(),
},
ClientError::Cluster {
command: "write_table".to_owned(),
code: 1,
message: "Control proxy may not serve heavy requests with input data".to_owned(),
raw: r#"{"code":1}"#.to_owned(),
},
] {
assert!(worth_asking_again(&hosts_fault), "{hosts_fault}");
assert!(attributable_to_the_host(&hosts_fault), "{hosts_fault}");
}
for requests_fault in [
ClientError::Http {
command: "write_table".to_owned(),
status: 404,
body: String::new(),
},
cluster_error(500, r#"{"code":500}"#),
ClientError::Decode {
command: "read_table".to_owned(),
reason: "cut short".to_owned(),
},
] {
assert!(
!attributable_to_the_host(&requests_fault),
"{requests_fault}"
);
}
}
#[test]
fn decode_and_config_errors_are_never_retried() {
assert!(!is_retriable(&ClientError::Config("no proxy".to_owned())));
assert!(!is_retriable(&ClientError::Decode {
command: "get".to_owned(),
reason: "not yson".to_owned(),
}));
}
#[test]
fn a_transient_failure_is_survived() {
let calls = RefCell::new(Vec::new());
let result = run(instant(5), Repeatable::Freely, "get", |is_retry| {
calls.borrow_mut().push(is_retry);
if calls.borrow().len() < 3 {
Err(cluster_error(105, r#"{"code":105}"#))
} else {
Ok(42)
}
});
assert_eq!(result.ok(), Some(42));
assert_eq!(*calls.borrow(), vec![false, true, true]);
}
#[test]
fn attempts_are_bounded() {
let calls = std::cell::Cell::new(0);
let result: Result<()> = run(instant(3), Repeatable::Freely, "get", |_| {
calls.set(calls.get() + 1);
Err(cluster_error(105, r#"{"code":105}"#))
});
assert!(result.is_err());
assert_eq!(calls.get(), 3, "three attempts, not three retries");
}
#[test]
fn a_heavy_command_is_sent_once() {
for once in [Repeatable::Heavy, Repeatable::Never] {
let calls = std::cell::Cell::new(0);
let result: Result<()> = run(instant(5), once, "write_table", |_| {
calls.set(calls.get() + 1);
Err(cluster_error(105, r#"{"code":105}"#))
});
assert!(result.is_err());
assert_eq!(
calls.get(),
1,
"{once:?}: heavy commands cannot be retried, whatever the policy says"
);
}
}
#[test]
fn a_hopeless_error_stops_immediately() {
let calls = std::cell::Cell::new(0);
let result: Result<()> = run(instant(5), Repeatable::Freely, "get", |_| {
calls.set(calls.get() + 1);
Err(cluster_error(500, r#"{"code":500}"#))
});
assert!(result.is_err());
assert_eq!(calls.get(), 1);
}
#[test]
fn no_retries_means_one_attempt() {
let calls = std::cell::Cell::new(0);
let result: Result<()> = run(RetryPolicy::none(), Repeatable::Freely, "get", |_| {
calls.set(calls.get() + 1);
Err(cluster_error(105, r#"{"code":105}"#))
});
assert!(result.is_err());
assert_eq!(calls.get(), 1);
}
#[test]
fn backoff_doubles_and_then_stops_growing() {
let policy = RetryPolicy::new(10, Duration::from_secs(1), Duration::from_secs(8));
assert_eq!(policy.backoff(1), Duration::from_secs(1));
assert_eq!(policy.backoff(2), Duration::from_secs(2));
assert_eq!(policy.backoff(3), Duration::from_secs(4));
assert_eq!(policy.backoff(4), Duration::from_secs(8));
assert_eq!(policy.backoff(5), Duration::from_secs(8));
assert_eq!(policy.backoff(64), Duration::from_secs(8));
assert_eq!(policy.backoff(u32::MAX), Duration::from_secs(8));
}
#[test]
fn a_job_gets_a_quiet_client_and_a_terminal_a_talkative_one() {
assert!(inside_job(Some("55aff293-7ef14284-3fe0384-3e07".into())));
assert!(!inside_job(None));
assert!(!inside_job(Some(String::new().into())));
}
#[test]
fn quiet_changes_the_reporting_and_nothing_else() {
let policy = RetryPolicy::default();
assert!(policy.report);
assert!(!policy.quiet().report);
assert!(policy.quiet().loud().report);
assert_eq!(policy.quiet().attempts, policy.attempts);
assert_eq!(policy.quiet().backoff(3), policy.backoff(3));
}
#[test]
fn a_policy_always_sends_the_request_at_least_once() {
assert_eq!(
RetryPolicy::new(0, Duration::ZERO, Duration::ZERO).attempts,
1
);
}
#[test]
fn a_replay_keeps_the_id_and_says_it_is_one() {
let original = MutationId::new();
let replay = original.as_retry();
assert_eq!(original.as_str(), replay.as_str());
assert!(!original.is_retry());
assert!(replay.is_retry());
assert_eq!(replay.as_retry().as_str(), original.as_str());
}
#[test]
fn mutation_ids_are_unique_and_shaped_like_guids() {
let ids: std::collections::HashSet<String> =
(0..10_000).map(|_| MutationId::new().id).collect();
assert_eq!(
ids.len(),
10_000,
"a repeated ID would deduplicate two different mutations"
);
for id in ids.iter().take(100) {
let groups: Vec<&str> = id.split('-').collect();
assert_eq!(groups.len(), 4, "{id}");
for group in groups {
assert!(!group.is_empty(), "{id}");
assert!(group.len() <= 8, "{id}");
assert!(group.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
}
}
}
}