Skip to main content

clickhouse_dfe/
ext.rs

1// Project:   clickhouse-dfe
2// File:      src/ext.rs
3// Purpose:   Extension traits on clickhouse::Client
4// Language:  Rust
5//
6// License:   Apache-2.0
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Extensions to [`clickhouse::Client`], built only on its public API.
10//! Upstream flattens every rejection into [`Error::BadResponse`] text, so
11//! branching on an error code means reading it back out.
12
13use std::future::Future;
14
15use clickhouse::Client;
16use clickhouse::error::{Error, Result};
17
18/// A `Code: N. DB::Exception: ...` rejection out of [`Error::BadResponse`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct ServerException {
22    /// Signed on the wire; small positives in practice.
23    pub code: i32,
24    /// The trailing `(UPPERCASE_NAME)` tag; older and proxied errors omit it.
25    pub name: Option<String>,
26    /// Body without the code prefix, the marker, the name tag and the version.
27    pub message: String,
28    /// The `Stack trace:` section, when the server included one.
29    pub stack_trace: Option<String>,
30}
31
32impl ServerException {
33    /// `None` for any other [`Error`] variant, and for a body that neither opens
34    /// with `Code: N.` nor is a bare code -- a proxy page or a bare status line.
35    /// A bare code parses with an empty `message`.
36    #[must_use]
37    pub fn parse(error: &Error) -> Option<Self> {
38        let Error::BadResponse(body) = error else {
39            return None;
40        };
41
42        // Upstream's `reason()` in `response.rs` substitutes the
43        // `X-ClickHouse-Exception-Code` header for a body that is empty, not
44        // UTF-8, or fails mid-read.
45        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        // `DB::Exception`, `DB::NetException` and the rest share this marker.
62        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        // Only a SHOUTY trailing paren run is the name tag; see the test below.
72        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    /// Off the same table as [`crate::Error::is_retriable_code`].
102    #[must_use]
103    pub fn is_retriable(&self) -> bool {
104        crate::Error::is_retriable_code(self.code)
105    }
106}
107
108/// Statement-level controls upstream's `Client` carries only as raw settings.
109/// `Sized` because the `with_*` methods consume and return the client.
110pub trait ClientExt: Sized {
111    /// `SELECT 1`; errors on a transport failure or a bad credential.
112    fn ping(&self) -> impl Future<Output = Result<()>> + Send;
113
114    /// Stop `query_id` and wait; matching nothing is not an error.
115    fn kill_query(&self, query_id: &str) -> impl Future<Output = Result<()>> + Send;
116
117    /// What `system.query_log` records and [`Self::kill_query`] matches on.
118    #[must_use]
119    fn with_query_id(self, query_id: impl Into<String>) -> Self;
120
121    /// Bind statements to a session, so `SET` and temp tables survive between.
122    #[must_use]
123    fn with_session_id(self, session_id: impl Into<String>) -> Self;
124
125    /// Run statements under `role`. `Client::with_default_roles` clears it.
126    #[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        // Upstream's `clear_roles` removes this exact key, so the clear works.
152        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    /// The shape a modern server sends: code, marker, message, name, version.
163    #[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    /// The socket pair a `NetException` ends in is not a name tag.
181    #[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            // A code but no message left after stripping.
230            "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    /// Declining a bare code loses the only field a caller classifies on.
242    #[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        // Upstream trims the body, and a retriable code still reads as one.
252        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        // 159 = TIMEOUT_EXCEEDED, 60 = UNKNOWN_TABLE.
267        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}