Skip to main content

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}