ytsaurus_client/error.rs
1//! Errors the client can fail with.
2
3use thiserror::Error;
4
5/// Shorthand for a client result.
6pub type Result<T, E = ClientError> = std::result::Result<T, E>;
7
8/// Something went wrong talking to the cluster.
9#[derive(Debug, Error)]
10pub enum ClientError {
11 /// The request could not be made, or the connection failed.
12 #[error("{command}: transport error: {source}")]
13 Transport {
14 /// The API command being attempted.
15 command: String,
16 /// The underlying HTTP error.
17 #[source]
18 source: Box<ureq::Error>,
19 },
20
21 /// The cluster reported an error.
22 ///
23 /// YTsaurus returns a structured error in the `X-YT-Error` header; the
24 /// message and code are lifted out of it so the common case reads well,
25 /// and the whole thing is kept in `raw` because the nested `inner_errors`
26 /// are often where the real cause is.
27 #[error("{command}: cluster error {code}: {message}")]
28 Cluster {
29 /// The API command that failed.
30 command: String,
31 /// YTsaurus error code.
32 code: i64,
33 /// Top-level error message.
34 message: String,
35 /// The full error document, as returned.
36 raw: String,
37 },
38
39 /// The cluster answered with an unexpected HTTP status and no usable error.
40 #[error("{command}: unexpected HTTP {status}{}", body_hint(.body))]
41 Http {
42 /// The API command that failed.
43 command: String,
44 /// The HTTP status returned.
45 status: u16,
46 /// Whatever body came back, truncated.
47 body: String,
48 },
49
50 /// A response could not be decoded.
51 #[error("{command}: could not decode the response: {reason}")]
52 Decode {
53 /// The API command whose response was unreadable.
54 command: String,
55 /// What went wrong.
56 reason: String,
57 },
58
59 /// Reading a local file failed.
60 #[error("reading {path}: {source}")]
61 Io {
62 /// The path that could not be read.
63 path: String,
64 /// The underlying I/O error.
65 #[source]
66 source: std::io::Error,
67 },
68
69 /// An operation finished in a state other than `completed`.
70 #[error("operation {id} finished as {state}{}", failure_hint(.error))]
71 OperationFailed {
72 /// The operation's ID.
73 id: String,
74 /// Its terminal state — `failed`, `aborted`, …
75 state: String,
76 /// The operation's error document, when it has one.
77 error: Option<String>,
78 },
79
80 /// The environment did not describe a cluster to talk to.
81 #[error("{0}")]
82 Config(String),
83}
84
85fn body_hint(body: &str) -> String {
86 if body.trim().is_empty() {
87 String::new()
88 } else {
89 format!(": {}", body.trim())
90 }
91}
92
93fn failure_hint(error: &Option<String>) -> String {
94 match error {
95 Some(e) if !e.trim().is_empty() => format!(": {}", e.trim()),
96 _ => String::new(),
97 }
98}
99
100impl ClientError {
101 /// Builds a [`ClientError::Cluster`] from an `X-YT-Error` document.
102 ///
103 /// Falls back to [`ClientError::Http`] if the document is not the shape
104 /// YTsaurus documents — better a slightly clumsy error than a panic while
105 /// reporting one.
106 pub(crate) fn from_yt_error(command: &str, status: u16, raw: &str) -> Self {
107 let parsed: Option<serde_json::Value> = serde_json::from_str(raw).ok();
108
109 match parsed {
110 Some(value) => {
111 let code = value
112 .get("code")
113 .and_then(serde_json::Value::as_i64)
114 .unwrap_or(-1);
115 let message = value
116 .get("message")
117 .and_then(serde_json::Value::as_str)
118 .unwrap_or("(no message)")
119 .to_owned();
120
121 // The useful detail is usually one level down.
122 let message = match innermost_message(&value) {
123 Some(inner) if inner != message => format!("{message}: {inner}"),
124 _ => message,
125 };
126
127 ClientError::Cluster {
128 command: command.to_owned(),
129 code,
130 message,
131 raw: raw.to_owned(),
132 }
133 }
134 None => ClientError::Http {
135 command: command.to_owned(),
136 status,
137 body: truncate(raw, 400),
138 },
139 }
140 }
141}
142
143/// Walks `inner_errors` to the deepest message, which is where YTsaurus tends
144/// to put the actual cause.
145fn innermost_message(value: &serde_json::Value) -> Option<String> {
146 let inner = value.get("inner_errors")?.as_array()?;
147 let first = inner.first()?;
148 innermost_message(first).or_else(|| {
149 first
150 .get("message")
151 .and_then(serde_json::Value::as_str)
152 .map(str::to_owned)
153 })
154}
155
156pub(crate) fn truncate(s: &str, limit: usize) -> String {
157 if s.len() <= limit {
158 return s.to_owned();
159 }
160 let mut end = limit;
161 while end > 0 && !s.is_char_boundary(end) {
162 end -= 1;
163 }
164 format!("{}… ({} bytes total)", &s[..end], s.len())
165}