use thiserror::Error;
use crate::jobs::JobFailure;
pub type Result<T, E = ClientError> = std::result::Result<T, E>;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientError {
#[error("{command}: transport error: {source}{}", certificate_advice(.source))]
Transport {
command: String,
#[source]
source: Box<ureq::Error>,
},
#[error("{command}: cluster error {code}: {message}")]
Cluster {
command: String,
code: i64,
message: String,
raw: String,
},
#[error("{command}: unexpected HTTP {status}{}", body_hint(.body))]
Http {
command: String,
status: u16,
body: String,
},
#[error(
"{command}: the proxy answered HTTP {status} and redirected to {location}, \
which this client did not follow: {refusal}{}",
redirect_advice(.heavy)
)]
Redirected {
command: String,
status: u16,
location: String,
refusal: RedirectRefusal,
heavy: bool,
},
#[error("{command}: could not decode the response: {reason}")]
Decode {
command: String,
reason: String,
},
#[error(
"{command}: the response ran past the {} this client will hold in \
memory{}",
cap_size(.limit),
streaming_advice(.command)
)]
ResponseTooLarge {
command: String,
limit: u64,
},
#[error(
"execute_batch: {} of {parts} parts were answered for before the batch stopped — \
that is where the answers stop, not where the effects do: the request that failed \
still ran its parts, and an Err among the answers applied nothing: {cause}",
.answered.len()
)]
BatchInterrupted {
answered: Vec<Result<ytsaurus_yson::YsonValue>>,
parts: usize,
#[source]
cause: Box<ClientError>,
},
#[error("reading {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("operation {id} finished as {state}{}{}", failure_hint(.error), jobs_hint(.jobs))]
OperationFailed {
id: String,
state: String,
error: Option<String>,
jobs: Vec<JobFailure>,
},
#[error("{path} cannot run on a cluster node: {reason}")]
NotAWorker {
path: String,
reason: String,
},
#[error("{0}")]
Config(String),
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RedirectRefusal {
#[error(
"the request carries credentials and the redirect leaves the host they \
were addressed to. Following it drops the `Authorization` header — \
`ureq` does that by default — and the cluster then answers with a \
credentials failure about a token that may be perfectly good. The \
token was not sent to the host that answered, so start with the \
redirect rather than with the token."
)]
Credentials,
#[error(
"the request body is read as it is sent, so this client cannot send it \
to the address the redirect named — a reader that has already begun \
to drain cannot be rewound. A write that arrived carrying no rows is \
answered much like one that succeeded, which is worse than failing. \
Send the body from memory, or address the host you meant to reach."
)]
Body,
#[error(
"the request carries data and the redirect leaves the host it was \
addressed to. Sending it on would hand the body to a host the caller \
never named, on the say-so of a header that arrived mid-flight. A \
redirect that stays on the same host is followed, body and all; to \
reach another one on purpose, ask the cluster for it and address it \
yourself."
)]
Payload,
#[error(
"the redirects did not end. This client follows a bounded number of \
them and that bound was reached, which is a loop rather than a route."
)]
TooMany,
}
fn certificate_advice(source: &ureq::Error) -> &'static str {
if crate::retry::settled_certificate_verdict(source) == Some("UnknownIssuer") {
" The chain does not end in a root this client trusts, which is the \
Mozilla bundle compiled in and not what the machine trusts: point \
YT_CA_BUNDLE at a PEM file of roots (the `yt` CLI reads the same \
variable; on Linux the system bundle is usually \
/etc/ssl/certs/ca-certificates.crt), or build with the \
`platform-verifier` feature to trust whatever the operating system \
does."
} else {
""
}
}
fn redirect_advice(heavy: &bool) -> &'static str {
if *heavy {
" Heavy commands belong on a heavy proxy: ask the cluster for one \
(`Client::heavy_proxy`) and address it directly."
} else {
""
}
}
fn cap_size(limit: &u64) -> String {
const MIB: u64 = 1024 * 1024;
if *limit >= MIB && limit.is_multiple_of(MIB) {
format!("{} MiB ({limit} bytes)", limit / MIB)
} else {
format!("{limit} bytes")
}
}
fn streaming_advice(command: &str) -> &'static str {
match command {
"read_table" => " — Client::read_table_streaming moves the same bytes without holding them",
"read_file" => " — Client::read_file_streaming moves the same bytes without holding them",
_ => "",
}
}
fn body_hint(body: &str) -> String {
if body.trim().is_empty() {
String::new()
} else {
format!(": {}", body.trim())
}
}
fn failure_hint(error: &Option<String>) -> String {
match error {
Some(e) if !e.trim().is_empty() => format!(": {}", e.trim()),
_ => String::new(),
}
}
fn jobs_hint(jobs: &[JobFailure]) -> String {
let mut out = String::new();
for job in jobs {
out.push_str("\n job ");
out.push_str(&job.id);
if let Some(address) = &job.address {
out.push_str(&format!(" on {address}"));
}
if let Some(error) = &job.error {
out.push_str(&format!(": {}", error.trim()));
}
if let Some(stderr) = &job.stderr
&& !stderr.trim().is_empty()
{
out.push_str("\n stderr:");
for line in stderr.lines() {
out.push_str("\n ");
out.push_str(line);
}
}
}
out
}
impl ClientError {
pub(crate) fn from_yt_error(command: &str, status: u16, raw: &str) -> Self {
let parsed: Option<serde_json::Value> = serde_json::from_str(raw).ok();
match parsed {
Some(value) => {
let code = value
.get("code")
.and_then(serde_json::Value::as_i64)
.unwrap_or(-1);
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("(no message)")
.to_owned();
let message = match innermost_message(&value) {
Some(inner) if inner != message => format!("{message}: {inner}"),
_ => message,
};
ClientError::Cluster {
command: command.to_owned(),
code,
message,
raw: raw.to_owned(),
}
}
None => ClientError::Http {
command: command.to_owned(),
status,
body: truncate(raw, 400),
},
}
}
}
fn innermost_message(value: &serde_json::Value) -> Option<String> {
let inner = value.get("inner_errors")?.as_array()?;
let first = inner.first()?;
innermost_message(first).or_else(|| {
first
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
}
pub(crate) fn truncate(s: &str, limit: usize) -> String {
if s.len() <= limit {
return s.to_owned();
}
let mut end = limit;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes total)", &s[..end], s.len())
}
pub(crate) fn tail(s: &str, limit: usize) -> String {
if s.len() <= limit {
return s.to_owned();
}
let mut start = s.len() - limit;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
format!(
"… ({} bytes total, last {} shown)\n{}",
s.len(),
s.len() - start,
&s[start..]
)
}
#[cfg(test)]
mod tests {
use super::*;
fn failure(jobs: Vec<JobFailure>) -> ClientError {
ClientError::OperationFailed {
id: "1-2-3-4".to_owned(),
state: "failed".to_owned(),
error: Some("Operation failed: User job failed".to_owned()),
jobs,
}
}
#[test]
fn a_failed_operation_reports_what_the_job_printed() {
let message = failure(vec![JobFailure {
id: "a-b-c-d".to_owned(),
address: Some("node.local:9012".to_owned()),
error: Some("User job failed: Process exited with code 101".to_owned()),
stderr: Some("boom: refusing row 7\nthread 'main' panicked".to_owned()),
}])
.to_string();
assert!(
message.contains("operation 1-2-3-4 finished as failed"),
"{message}"
);
assert!(
message.contains("job a-b-c-d on node.local:9012"),
"{message}"
);
assert!(
message.contains("Process exited with code 101"),
"{message}"
);
assert!(
message.contains("\n thread 'main' panicked"),
"{message}"
);
}
#[test]
fn a_failure_with_no_job_information_stays_one_line() {
let message = failure(Vec::new()).to_string();
assert_eq!(
message,
"operation 1-2-3-4 finished as failed: Operation failed: User job failed"
);
}
#[test]
fn a_job_with_empty_stderr_gets_no_stderr_block() {
let message = failure(vec![JobFailure {
id: "a-b-c-d".to_owned(),
address: None,
error: None,
stderr: Some(" \n".to_owned()),
}])
.to_string();
assert!(message.ends_with("job a-b-c-d"), "{message}");
}
#[test]
fn tail_keeps_the_end_and_says_how_much_it_dropped() {
let long = format!("{}the panic", "chatter\n".repeat(100));
let kept = tail(&long, 20);
assert!(kept.ends_with("the panic"), "{kept}");
assert!(
kept.contains(&format!("{} bytes total", long.len())),
"{kept}"
);
assert_eq!(tail("short", 20), "short");
}
#[test]
fn tail_does_not_cut_a_character_in_half() {
let text = "ошибка в джобе";
for limit in 0..=text.len() {
let kept = tail(text, limit);
let suffix = kept.rsplit_once('\n').map_or(kept.as_str(), |(_, s)| s);
assert!(text.ends_with(suffix), "limit {limit}: {kept:?}");
assert!(suffix.len() <= limit.max(text.len()), "limit {limit}");
}
}
fn transport(message: &str) -> ClientError {
ClientError::Transport {
command: "get".to_owned(),
source: Box::new(ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
message.to_owned(),
))),
}
}
#[test]
fn an_untrusted_root_names_the_two_things_that_change_it() {
let message = transport("invalid peer certificate: UnknownIssuer").to_string();
assert!(message.contains("UnknownIssuer"), "{message}");
assert!(message.contains("YT_CA_BUNDLE"), "{message}");
assert!(message.contains("platform-verifier"), "{message}");
}
#[test]
fn other_transport_failures_are_left_alone() {
for message in [
"invalid peer certificate: certificate not valid for name \
\"cluster.example.net\"",
"connection refused",
"invalid peer certificate: Other(OtherError(\"UnknownIssuer lookup failed\"))",
] {
let rendered = transport(message).to_string();
assert_eq!(rendered, format!("get: transport error: io: {message}"));
}
}
}