1use std::future::Future;
14
15use clickhouse::Client;
16use clickhouse::error::{Error, Result};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct ServerException {
22 pub code: i32,
24 pub name: Option<String>,
26 pub message: String,
28 pub stack_trace: Option<String>,
30}
31
32impl ServerException {
33 #[must_use]
37 pub fn parse(error: &Error) -> Option<Self> {
38 let Error::BadResponse(body) = error else {
39 return None;
40 };
41
42 if let Ok(code) = body.trim().parse::<i32>() {
46 return Some(Self {
47 code,
48 name: None,
49 message: String::new(),
50 stack_trace: None,
51 });
52 }
53
54 let code: i32 = body
55 .strip_prefix("Code: ")?
56 .split_once('.')
57 .map(|(code, _)| code.trim())?
58 .parse()
59 .ok()?;
60
61 let after_marker = body
63 .split_once("Exception: ")
64 .map_or(body.trim(), |(_, rest)| rest.trim());
65
66 let without_version = match after_marker.rfind("(version ") {
67 Some(i) => after_marker[..i].trim_end_matches([' ', '.', ',']),
68 None => after_marker,
69 };
70
71 let mut without_name = without_version;
73 let mut name = None;
74 if let Some(open) = without_version.strip_suffix(')').and_then(|s| s.rfind('(')) {
75 let candidate = &without_version[open + 1..without_version.len() - 1];
76 let shouty = candidate
77 .bytes()
78 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_');
79 if !candidate.is_empty() && shouty {
80 without_name = without_version[..open].trim_end_matches([':', ' ', '.', ',']);
81 name = Some(candidate.to_owned());
82 }
83 }
84
85 let (message, stack_trace) = match without_name.find("Stack trace:") {
86 Some(i) => (
87 without_name[..i].trim_end().to_owned(),
88 Some(without_name[i + "Stack trace:".len()..].trim().to_owned()),
89 ),
90 None => (without_name.trim().to_owned(), None),
91 };
92
93 (!message.is_empty()).then_some(Self {
94 code,
95 name,
96 message,
97 stack_trace,
98 })
99 }
100
101 #[must_use]
103 pub fn is_retriable(&self) -> bool {
104 crate::Error::is_retriable_code(self.code)
105 }
106}
107
108pub trait ClientExt: Sized {
111 fn ping(&self) -> impl Future<Output = Result<()>> + Send;
113
114 fn kill_query(&self, query_id: &str) -> impl Future<Output = Result<()>> + Send;
116
117 #[must_use]
119 fn with_query_id(self, query_id: impl Into<String>) -> Self;
120
121 #[must_use]
123 fn with_session_id(self, session_id: impl Into<String>) -> Self;
124
125 #[must_use]
127 fn with_role(self, role: impl Into<String>) -> Self;
128}
129
130impl ClientExt for Client {
131 async fn ping(&self) -> Result<()> {
132 self.query("SELECT 1").execute().await
133 }
134
135 async fn kill_query(&self, query_id: &str) -> Result<()> {
136 self.query("KILL QUERY WHERE query_id = ? SYNC")
137 .bind(query_id)
138 .execute()
139 .await
140 }
141
142 fn with_query_id(self, query_id: impl Into<String>) -> Self {
143 self.with_setting("query_id", query_id)
144 }
145
146 fn with_session_id(self, session_id: impl Into<String>) -> Self {
147 self.with_setting("session_id", session_id)
148 }
149
150 fn with_role(self, role: impl Into<String>) -> Self {
151 self.with_setting("role", role)
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use clickhouse::test::{Mock, handlers};
159
160 use super::*;
161
162 #[test]
164 fn parse_reads_code_name_and_message() {
165 let err = Error::BadResponse(
166 "Code: 469. DB::Exception: Constraint `x_lt_10` for table t is violated at row 5000. \
167 (VIOLATED_CONSTRAINT) (version 26.2.4.23 (official build))"
168 .into(),
169 );
170 let exc = ServerException::parse(&err).expect("a well-formed exception body parses");
171 assert_eq!(exc.code, 469);
172 assert_eq!(exc.name.as_deref(), Some("VIOLATED_CONSTRAINT"));
173 assert_eq!(
174 exc.message,
175 "Constraint `x_lt_10` for table t is violated at row 5000"
176 );
177 assert!(exc.stack_trace.is_none());
178 }
179
180 #[test]
182 fn parse_keeps_trailing_parens_that_are_not_a_name_tag() {
183 let err = Error::BadResponse(
184 "Code: 210. DB::NetException: I/O error: Broken pipe, while writing to socket \
185 (127.0.0.1:9000 -> 127.0.0.1:54646). (NETWORK_ERROR) (version 23.8.8.20)"
186 .into(),
187 );
188 let exc = ServerException::parse(&err).expect("a NetException body parses");
189 assert_eq!(exc.code, 210);
190 assert_eq!(exc.name.as_deref(), Some("NETWORK_ERROR"));
191 assert_eq!(
192 exc.message,
193 "I/O error: Broken pipe, while writing to socket (127.0.0.1:9000 -> 127.0.0.1:54646)"
194 );
195 }
196
197 #[test]
198 fn parse_splits_the_stack_trace_out_of_the_message() {
199 let err = Error::BadResponse(
200 "Code: 36. DB::Exception: Bad arguments. Stack trace:\n0. ./Common/Exception.cpp:99\n\
201 1. ./Functions/foo.cpp:42\n (BAD_ARGUMENTS) (version 26.2.4.23 (official build))"
202 .into(),
203 );
204 let exc = ServerException::parse(&err).expect("a body with a trace parses");
205 assert_eq!(exc.code, 36);
206 assert_eq!(exc.message, "Bad arguments.");
207 let trace = exc.stack_trace.expect("the trace section is captured");
208 assert!(trace.contains("Exception.cpp:99"), "got {trace:?}");
209 assert!(trace.contains("foo.cpp:42"), "got {trace:?}");
210 }
211
212 #[test]
213 fn parse_tolerates_a_missing_name_tag() {
214 let err = Error::BadResponse(
215 "Code: 999. DB::Exception: Generic problem (version 26.2.4.23 (official build))".into(),
216 );
217 let exc = ServerException::parse(&err).expect("a body without a name tag parses");
218 assert_eq!(exc.code, 999);
219 assert!(exc.name.is_none());
220 assert_eq!(exc.message, "Generic problem");
221 }
222
223 #[test]
224 fn parse_declines_anything_that_is_not_an_exception_body() {
225 for body in [
226 "404 Not Found",
227 "",
228 "DB::Exception: oops",
229 "Code: 60. DB::Exception: (UNKNOWN_TABLE)",
231 ] {
232 let err = Error::BadResponse(body.into());
233 assert!(
234 ServerException::parse(&err).is_none(),
235 "{body:?} should not parse"
236 );
237 }
238 assert!(ServerException::parse(&Error::TimedOut).is_none());
239 }
240
241 #[test]
243 fn parse_accepts_the_bare_code_upstream_falls_back_to() {
244 let exc = ServerException::parse(&Error::BadResponse("117".into()))
245 .expect("a bare exception code is a rejection, not junk");
246 assert_eq!(exc.code, 117);
247 assert_eq!(exc.name, None);
248 assert!(exc.message.is_empty(), "no message is on the wire to read");
249 assert!(exc.stack_trace.is_none());
250
251 let exc = ServerException::parse(&Error::BadResponse(" 202 ".into()))
253 .expect("a padded bare code still parses");
254 assert_eq!(exc.code, 202);
255 assert!(exc.is_retriable());
256 }
257
258 #[test]
259 fn is_retriable_shares_the_error_code_table() {
260 let exc = |code| ServerException {
261 code,
262 name: None,
263 message: "x".into(),
264 stack_trace: None,
265 };
266 assert!(exc(159).is_retriable());
268 assert!(!exc(60).is_retriable());
269 }
270
271 #[tokio::test]
272 async fn ping_sends_select_1() {
273 let mock = Mock::new();
274 let recorded = mock.add(handlers::record_ddl());
275 let client = Client::default().with_url(mock.url());
276
277 client.ping().await.expect("the mock answers 200");
278
279 assert_eq!(recorded.query().await, "SELECT 1");
280 }
281
282 #[tokio::test]
283 async fn kill_query_binds_and_quotes_the_query_id() {
284 let mock = Mock::new();
285 let recorded = mock.add(handlers::record_ddl());
286 let client = Client::default().with_url(mock.url());
287
288 client
289 .kill_query("q-1")
290 .await
291 .expect("the mock answers 200");
292
293 assert_eq!(
294 recorded.query().await,
295 "KILL QUERY WHERE query_id = 'q-1' SYNC"
296 );
297 }
298
299 #[test]
300 fn the_statement_knobs_land_as_settings() {
301 let client = Client::default()
302 .with_query_id("q-1")
303 .with_session_id("s-1")
304 .with_role("reader");
305
306 assert_eq!(client.get_setting("query_id"), Some("q-1"));
307 assert_eq!(client.get_setting("session_id"), Some("s-1"));
308 assert_eq!(client.get_setting("role"), Some("reader"));
309 assert_eq!(client.with_default_roles().get_setting("role"), None);
310 }
311}